mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Integration: track t11
# Conflicts: # ROADMAP.md # apps/gridcraft-web/Cargo.toml # crates/ui-egui/src/i18n.rs # docs/control-protocol.md # docs/gaps.md # docs/target-app-parity.md # docs/ui-parity.md
This commit is contained in:
@@ -782,15 +782,24 @@ fn forecast_grid(x: &[f64], f: &[Option<f64>], se_window: usize, p: &Json) -> Gr
|
||||
g.put(idx(i), 0, fi.map(Value::number).unwrap_or_else(na));
|
||||
}
|
||||
if flag(p, "standardErrors") {
|
||||
// A running sum of squared residuals over the last `se_window` points: O(n), not
|
||||
// O(n·window). A point without a forecast restarts it, so every window touching it is #N/A.
|
||||
let sq = |j: usize| Some((x.get(j)? - (*f.get(j)?)?).powi(2));
|
||||
let (mut acc, mut run) = (0.0, 0usize);
|
||||
for i in 0..f.len() {
|
||||
let start = (i + 1).checked_sub(se_window);
|
||||
let v = start.and_then(|s0| {
|
||||
let mut acc = 0.0;
|
||||
for j in s0..=i {
|
||||
acc += (x.get(j)? - (*f.get(j)?)?).powi(2);
|
||||
match sq(i) {
|
||||
Some(t) => {
|
||||
acc += t;
|
||||
run += 1;
|
||||
}
|
||||
Some((acc / se_window as f64).sqrt())
|
||||
});
|
||||
None => (acc, run) = (0.0, 0),
|
||||
}
|
||||
if run > se_window {
|
||||
acc -= i.checked_sub(se_window).and_then(sq).unwrap_or(0.0);
|
||||
run = se_window;
|
||||
}
|
||||
// Rounding in the subtraction can leave a tiny negative sum.
|
||||
let v = (run == se_window).then(|| (acc.max(0.0) / se_window as f64).sqrt());
|
||||
g.put(idx(i), 1, v.map(Value::number).unwrap_or_else(na));
|
||||
}
|
||||
}
|
||||
@@ -1190,6 +1199,37 @@ mod tests {
|
||||
x
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecast_standard_errors_match_the_full_window_rescan() {
|
||||
// #1043: the running sum must give what re-adding every window gave, gaps included.
|
||||
let x: [f64; 11] = [2.0, 4.0, 5.0, 4.0, 5.0, 7.0, 1.0, 3.0, 8.0, 6.0, 2.0];
|
||||
let f = [None, Some(3.0), Some(4.5), None, Some(4.0), Some(6.5), Some(2.0), Some(2.5), Some(7.0), Some(5.0), Some(4.0)];
|
||||
let rescan = |i: usize, w: usize| -> Value {
|
||||
let v = (i + 1).checked_sub(w).and_then(|s0| {
|
||||
let mut acc = 0.0;
|
||||
for j in s0..=i {
|
||||
acc += (x.get(j)? - (*f.get(j)?)?).powi(2);
|
||||
}
|
||||
Some((acc / w as f64).sqrt())
|
||||
});
|
||||
v.map(Value::number).unwrap_or(Value::Error(gridcraft_core::CellError::NA))
|
||||
};
|
||||
for w in 1..=5 {
|
||||
let g = super::forecast_grid(&x, &f, w, &json!({"standardErrors": true}));
|
||||
for i in 0..f.len() {
|
||||
let (got, want) = (g.cells.get(&(i as u32, 1)).cloned().unwrap_or(Value::Empty), rescan(i, w));
|
||||
match (got.as_f64(), want.as_f64()) {
|
||||
(Some(a), Some(b)) => assert!(close(a, b, 1e-12), "window {w}, row {i}: {a} vs {b}"),
|
||||
_ => assert_eq!(got, want, "window {w}, row {i}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Row 3 has no forecast: windows through it are #N/A, the next full window after it is not.
|
||||
let g = super::forecast_grid(&x, &f, 3, &json!({"standardErrors": true}));
|
||||
assert_eq!(g.cells.get(&(5, 1)), Some(&Value::Error(gridcraft_core::CellError::NA)));
|
||||
assert!(g.cells.get(&(6, 1)).and_then(Value::as_f64).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptive_statistics_of_one_to_five() {
|
||||
// 1..5: mean 3, s = √2.5, kurtosis −1.2, skewness 0 (textbook values).
|
||||
|
||||
@@ -464,18 +464,53 @@ fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::C
|
||||
}
|
||||
}
|
||||
|
||||
/// Recomputes an automatic row height from its content: the largest font, and wrapped or
|
||||
/// multi-line text. Rows with a user-set height keep it.
|
||||
/// Lines a single text line needs when word-wrapped into `width` points. The grid wraps whole
|
||||
/// words (egui's LayoutJob), so dividing the total width by the column width undercounts: each
|
||||
/// drawn line ends early at the last word that fits. A word wider than the column breaks
|
||||
/// mid-word, the way egui does. (#1969)
|
||||
fn wrapped_line_count(line: &str, width: f32, size: f32, bold: bool) -> f32 {
|
||||
let word_w = |w: &str| crate::cmd::format::approx_text_width(w, size, bold);
|
||||
let space_w = word_w(" ");
|
||||
let mut lines = 1.0f32;
|
||||
let mut used = 0.0f32;
|
||||
for word in line.split_whitespace() {
|
||||
let w = word_w(word);
|
||||
if w > width {
|
||||
// wider than the column: egui fills the current line, then breaks it across lines
|
||||
if used > 0.0 {
|
||||
lines += 1.0;
|
||||
}
|
||||
let chunks = (w / width).ceil();
|
||||
lines += chunks - 1.0;
|
||||
used = w - (chunks - 1.0) * width;
|
||||
continue;
|
||||
}
|
||||
let gap = if used > 0.0 { space_w } else { 0.0 };
|
||||
if used + gap + w > width {
|
||||
lines += 1.0;
|
||||
used = w;
|
||||
} else {
|
||||
used += gap + w;
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// Recomputes an automatic row height from its content: the largest font, and wrapped text.
|
||||
/// Rows with a user-set height keep it.
|
||||
///
|
||||
/// Line breaks in a cell's text count only when the cell wraps, as in Excel: with Wrap Text off
|
||||
/// a multi-line value shows on one line and AutoFit gives a one-line row.
|
||||
pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
|
||||
let Some(sh) = wb.sheet(si) else { return };
|
||||
if sh.rows.get(&row).is_some_and(|i| i.custom) {
|
||||
return;
|
||||
}
|
||||
let default = sh.default_row_height;
|
||||
// Fast path: rows of default-size, unwrapped, single-line content keep the default height.
|
||||
// Fast path: rows of default-size, unwrapped content keep the default height.
|
||||
let plain = sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1).all(|(_, cell)| {
|
||||
let st = wb.styles.get(cell.style);
|
||||
st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE && !st.align.wrap && !cell.value.as_text().is_some_and(|t| t.contains('\n'))
|
||||
st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE && !st.align.wrap
|
||||
});
|
||||
if plain {
|
||||
if sh.rows.get(&row).is_some_and(|i| i.size.is_some())
|
||||
@@ -493,22 +528,24 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
|
||||
}
|
||||
let mut need: f32 = default;
|
||||
for (c, cell) in sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1) {
|
||||
// Merged cells never auto-fit their rows, like Excel (column autofit skips them too).
|
||||
if sh.merge_at(CellRef::new(row, c)).is_some_and(|m| !m.is_single()) {
|
||||
continue;
|
||||
}
|
||||
let st = wb.styles.get(cell.style);
|
||||
let size = st.font.size;
|
||||
let line = (size * 1.8).round().max(default * size / gridcraft_model::DEFAULT_FONT_SIZE);
|
||||
// Scaled from an 11pt row's height, not the sheet's default: a sheet whose default
|
||||
// already fits a bigger font (21pt rows for 16pt text) would otherwise scale it twice.
|
||||
let line = (size * 1.8).round().max(gridcraft_model::DEFAULT_ROW_HEIGHT * size / gridcraft_model::DEFAULT_FONT_SIZE);
|
||||
let mut lines = 1.0f32;
|
||||
if !cell.value.is_empty() || cell.formula.is_some() {
|
||||
let text = crate::display::cell_text(wb, sh, CellRef::new(row, c));
|
||||
let explicit = text.lines().count().max(1) as f32;
|
||||
lines = if st.align.wrap {
|
||||
// Only a wrapping cell is laid out over several lines: each explicit line break
|
||||
// starts one, and each line folds at the column width.
|
||||
if st.align.wrap {
|
||||
let text = crate::display::cell_text(wb, sh, CellRef::new(row, c));
|
||||
let w = sh.col_width(c).max(1.0) - 6.0;
|
||||
text.lines()
|
||||
.map(|l| (crate::cmd::format::approx_text_width(l, size, st.font.bold) / w.max(1.0)).ceil().max(1.0))
|
||||
.sum::<f32>()
|
||||
.max(1.0)
|
||||
} else {
|
||||
explicit
|
||||
};
|
||||
lines = text.lines().map(|l| wrapped_line_count(l, w.max(1.0), size, st.font.bold)).sum::<f32>().max(1.0);
|
||||
}
|
||||
} else if st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE {
|
||||
continue;
|
||||
}
|
||||
|
||||
+201
-56
@@ -1863,13 +1863,11 @@ fn limits_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Result<Lines> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Writes the reports on new sheets ("Answer Report 1"…) before the model's sheet, in one step;
|
||||
/// the model's sheet stays active. Returns the new sheets' names.
|
||||
fn write_reports(s: &mut Session, kinds: &[ReportKind], inp: &ReportInput) -> Result<Vec<String>> {
|
||||
/// Builds every report before the solution or saved model can change.
|
||||
fn prepare_reports(d: &DocState, kinds: &[ReportKind], inp: &ReportInput) -> Result<Vec<(String, Lines)>> {
|
||||
if kinds.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let d = s.doc()?;
|
||||
if d.wb.protected_structure {
|
||||
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
|
||||
}
|
||||
@@ -1886,41 +1884,48 @@ fn write_reports(s: &mut Session, kinds: &[ReportKind], inp: &ReportInput) -> Re
|
||||
.ok_or_else(|| EngineError::Other("Too many report sheets.".into()))?;
|
||||
built.push((name, lines));
|
||||
}
|
||||
Ok(built)
|
||||
}
|
||||
|
||||
/// Inserts prepared reports before the model's sheet, which stays active.
|
||||
fn write_reports(cx: &mut Ctx, at: usize, built: Vec<(String, Lines)>) -> Vec<String> {
|
||||
let names: Vec<String> = built.iter().map(|(n, _)| n.clone()).collect();
|
||||
let at = inp.model.sheet;
|
||||
let count = built.len();
|
||||
edit(s, |cx| {
|
||||
let bold = cx.wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true);
|
||||
for (i, (name, lines)) in built.into_iter().enumerate() {
|
||||
let mut sheet = Sheet::new(name);
|
||||
for (r, c, v, b) in lines {
|
||||
let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT };
|
||||
sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style });
|
||||
}
|
||||
for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 120.0), (6, 120.0), (7, 120.0)] {
|
||||
sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() });
|
||||
}
|
||||
cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet));
|
||||
if count == 0 {
|
||||
return names;
|
||||
}
|
||||
let bold = cx.wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true);
|
||||
for (i, (name, lines)) in built.into_iter().enumerate() {
|
||||
let mut sheet = Sheet::new(name);
|
||||
for (r, c, v, b) in lines {
|
||||
let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT };
|
||||
sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style });
|
||||
}
|
||||
for nm in cx.wb.names.iter_mut() {
|
||||
if let Some(sc) = nm.scope.as_mut()
|
||||
&& *sc >= at
|
||||
{
|
||||
*sc += count;
|
||||
}
|
||||
for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 120.0), (6, 120.0), (7, 120.0)] {
|
||||
sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() });
|
||||
}
|
||||
if cx.wb.active_sheet >= at {
|
||||
cx.wb.active_sheet += count;
|
||||
cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet));
|
||||
}
|
||||
for nm in cx.wb.names.iter_mut() {
|
||||
if let Some(sc) = nm.scope.as_mut()
|
||||
&& *sc >= at
|
||||
{
|
||||
*sc += count;
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
if cx.wb.active_sheet >= at {
|
||||
cx.wb.active_sheet += count;
|
||||
}
|
||||
cx.structural = true;
|
||||
names
|
||||
}
|
||||
|
||||
fn insert_report_selections(s: &mut Session, at: usize, count: usize) {
|
||||
if let Ok(d) = s.doc_mut() {
|
||||
for i in 0..count {
|
||||
d.sheet_selections.insert((at + i).min(d.sheet_selections.len()), crate::Selection::default());
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// `data.solverReport`: reports for a solution of the sheet's saved model.
|
||||
@@ -1973,7 +1978,9 @@ fn report(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
.map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS));
|
||||
let message = str_param(p, "message").unwrap_or("").chars().take(500).collect();
|
||||
let inp = ReportInput { model: &model, prob: &prob, original, solution, message, budget };
|
||||
let sheets = write_reports(s, &kinds, &inp)?;
|
||||
let built = prepare_reports(s.doc()?, &kinds, &inp)?;
|
||||
let sheets = edit(s, |cx| Ok(write_reports(cx, model.sheet, built)))?;
|
||||
insert_report_selections(s, model.sheet, sheets.len());
|
||||
Ok(json!({"sheets": sheets}))
|
||||
}
|
||||
|
||||
@@ -2068,41 +2075,53 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
let original: serde_json::Map<String, Json> = model.vars.iter().zip(&x0).map(|(c, v)| (c.a1(), json!(v))).collect();
|
||||
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(())
|
||||
})?;
|
||||
}
|
||||
let built = match (&reports, &solution) {
|
||||
(Some(kinds), Some(x)) => {
|
||||
let inp = ReportInput { model: &model, prob: &prob, original: x0, solution: x.clone(), message: message.to_string(), budget };
|
||||
prepare_reports(s.doc()?, kinds, &inp)?
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let count = built.len();
|
||||
let before = s.doc()?.wb.clone();
|
||||
let mut out = json!({
|
||||
"status": status,
|
||||
"message": message,
|
||||
"objective": objective,
|
||||
"values": values,
|
||||
"original": original,
|
||||
"model": model_params(&s.doc()?.wb, &model),
|
||||
"model": model_params(&before, &model),
|
||||
"evaluations": evaluations,
|
||||
"changed": changed,
|
||||
});
|
||||
// Reports describe a solution; they are part of the solve's undo step.
|
||||
if let (Some(kinds), Some(x)) = (reports, solution) {
|
||||
let inp = ReportInput { model: &model, prob: &prob, original: x0, solution: x, message: message.to_string(), budget };
|
||||
out["reports"] = json!(write_reports(s, &kinds, &inp)?);
|
||||
let names = if changed || save || count > 0 {
|
||||
let sheet = model.sheet;
|
||||
edit(s, |cx| {
|
||||
if changed && let Some(x) = &solution {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, v) in model.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 &model.vars {
|
||||
cx.touch(sheet + count, *c);
|
||||
}
|
||||
}
|
||||
if save {
|
||||
save_model(&mut cx.wb, &model);
|
||||
}
|
||||
if count > 0 {
|
||||
// Inserting sheets makes this structural, so retain the variable edit's array check.
|
||||
check_arrays(&before, &cx.wb)?;
|
||||
}
|
||||
Ok(write_reports(cx, sheet, built))
|
||||
})?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
insert_report_selections(s, model.sheet, count);
|
||||
if reports.is_some() && solution.is_some() {
|
||||
out["reports"] = json!(names);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -2390,6 +2409,132 @@ mod tests {
|
||||
assert_eq!(at(&s, "Answer Report 1", &format!("E{r}")), Value::number(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_solver_reports_preserve_the_document_and_history() {
|
||||
for (protected, method, constraints, reports, error) in [
|
||||
(true, "simplex", json!(["A1 <= 4"]), json!(["answer"]), "workbook is protected"),
|
||||
(false, "simplex", json!(["A1 <= 4", "A1 int"]), json!(["answer", "limits"]), "integer or binary"),
|
||||
(false, "evolutionary", json!(["A1 <= 4"]), json!(["answer", "limits"]), "Simplex LP and GRG"),
|
||||
] {
|
||||
for (keep, save) in [(true, true), (false, true), (true, false), (false, false)] {
|
||||
let mut s = s();
|
||||
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
|
||||
s.execute(
|
||||
"data.solver",
|
||||
json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 2"], "method": "simplex", "keep": false}),
|
||||
)
|
||||
.unwrap();
|
||||
set(&mut s, &[("C1", "redo")]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let d = s.doc_mut().unwrap();
|
||||
std::sync::Arc::make_mut(&mut d.wb).protected_structure = protected;
|
||||
d.saved = d.wb.clone();
|
||||
let before = d.clone();
|
||||
let journal = s.journal.clone();
|
||||
let err = s
|
||||
.execute(
|
||||
"data.solver",
|
||||
json!({"objective": "B1", "variables": "A1", "constraints": constraints, "method": method,
|
||||
"reports": reports, "keep": keep, "saveModel": save}),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains(error), "{err}");
|
||||
let d = s.doc().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb), "{method}: keep={keep}, save={save}: {err}");
|
||||
assert!(!d.is_dirty());
|
||||
assert_eq!(d.revision, before.revision);
|
||||
assert_eq!(d.selection, before.selection);
|
||||
assert_eq!(d.sheet_selections, before.sheet_selections);
|
||||
for (after, before) in [(&d.undo, &before.undo), (&d.redo, &before.redo)] {
|
||||
assert_eq!(after.len(), before.len());
|
||||
for (a, b) in after.iter().zip(before) {
|
||||
assert!(std::sync::Arc::ptr_eq(&a.wb, &b.wb));
|
||||
assert_eq!((&a.label, &a.selection), (&b.label, &b.selection));
|
||||
}
|
||||
}
|
||||
assert_eq!(s.journal, journal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_reports_and_optional_solution_and_model_share_one_undo() {
|
||||
for (keep, save) in [(true, true), (false, true), (true, false), (false, false)] {
|
||||
let mut s = s();
|
||||
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
|
||||
let before = s.doc().unwrap().clone();
|
||||
let out = s
|
||||
.execute(
|
||||
"data.solver",
|
||||
json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4"], "method": "simplex",
|
||||
"reports": ["answer"], "keep": keep, "saveModel": save}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out["status"], "optimal");
|
||||
assert_eq!(out["reports"], json!(["Answer Report 1"]));
|
||||
assert_eq!(out["model"]["objective"], "Sheet1!B1");
|
||||
assert_eq!(n(&s, "A1"), if keep { 4.0 } else { 1.0 });
|
||||
assert_eq!(saved(&s), if save { "solver_opt" } else { "" });
|
||||
assert_eq!(s.doc().unwrap().undo.len(), before.undo.len() + 1);
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().name, "Sheet1");
|
||||
let r = row_of(&s, "Answer Report 1", "$A$1") + 1;
|
||||
assert_eq!(at(&s, "Answer Report 1", &format!("E{r}")), Value::number(4.0));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before.wb));
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
|
||||
assert_eq!(n(&s, "A1"), if keep { 4.0 } else { 1.0 });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_reports_keep_row_autofit_on_the_model_sheet() {
|
||||
let mut s = s();
|
||||
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
|
||||
s.execute("home.columnWidth", json!({"cols": "A:A", "width": 30})).unwrap();
|
||||
s.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap();
|
||||
let before = s.doc().unwrap().wb.active().unwrap().row_height(0);
|
||||
s.execute(
|
||||
"data.solver",
|
||||
json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 1000000"], "method": "simplex", "reports": ["answer"]}),
|
||||
)
|
||||
.unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.name, "Sheet1");
|
||||
assert_eq!(sh.value(CellRef::new(0, 0)), Value::number(1_000_000.0));
|
||||
assert!(sh.row_height(0) > before, "the solved wrapped number must re-fit its row after the report shifts its sheet");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(0), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_report_insertion_keeps_variable_edit_guards() {
|
||||
for array in [false, true] {
|
||||
let mut s = s();
|
||||
set(&mut s, &[("A2", "1"), ("B1", "=2*A2")]);
|
||||
let error = if array {
|
||||
s.execute("selection.set", json!({"range": "A1:A2"})).unwrap();
|
||||
s.execute("cell.set", json!({"input": "=1", "array": true})).unwrap();
|
||||
"part of an array"
|
||||
} else {
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
"protected sheet"
|
||||
};
|
||||
let before = s.doc().unwrap().clone();
|
||||
let err = s
|
||||
.execute(
|
||||
"data.solver",
|
||||
json!({"objective": "B1", "variables": "A2", "constraints": ["A2 <= 4"], "method": "simplex", "reports": ["answer"]}),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains(error), "{err}");
|
||||
let d = s.doc().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb));
|
||||
assert_eq!(d.undo.len(), before.undo.len());
|
||||
assert_eq!(d.sheet_selections, before.sheet_selections);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_report_of_a_nonlinear_model() {
|
||||
// The largest rectangle with perimeter at most 20 (5 × 5): with the height held at 5, the
|
||||
|
||||
@@ -162,7 +162,14 @@ fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
|
||||
if wb.sheets.is_empty() {
|
||||
wb = Workbook::new();
|
||||
}
|
||||
Ok((wb, vec![]))
|
||||
// The file's band placement and cell count are derived state: rebuild them from the
|
||||
// cells themselves, dropping what is outside the sheet grid (#1008).
|
||||
let dropped: usize = wb.sheets.iter_mut().map(|sh| std::sync::Arc::make_mut(sh).cells.rebuild()).sum();
|
||||
let mut warnings = Vec::new();
|
||||
if dropped > 0 {
|
||||
warnings.push(format!("{name}: {dropped} cells outside the 1,048,576 × 16,384 grid were dropped"));
|
||||
}
|
||||
Ok((wb, warnings))
|
||||
}
|
||||
Some(FileKind::Tsv) => {
|
||||
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() };
|
||||
|
||||
@@ -1315,6 +1315,35 @@ fn open_grows_default_height_rows_to_their_font() {
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(0), default_h, "11pt row stays default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_breaks_grow_a_row_only_when_the_cell_wraps() {
|
||||
// Excel: a cell holding "a\nb" with Wrap Text off displays on one line and AutoFit gives a
|
||||
// one-line row; with Wrap Text on it gets one line per break.
|
||||
// Typing a break (Alt+Enter) turns wrap on, as in Excel, so the unwrapped case comes from a
|
||||
// file, built with the model API and exported without row heights.
|
||||
let text = "first line\n\n\nfourth line";
|
||||
let bytes = {
|
||||
let mut wb = gridcraft_model::Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.set_value(CellRef::new(1, 1), Value::text(text));
|
||||
gridcraft_xlsx::write_xlsx(&wb).unwrap()
|
||||
};
|
||||
let default_h = gridcraft_model::Workbook::new().active().unwrap().row_height(1);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", json!({"name": "notes.xlsx", "base64": crate::io::base64_encode(&bytes)})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(1), default_h, "an unwrapped multi-line cell keeps the default row height on open");
|
||||
// Turning wrap on lays the text out over its lines and the row grows to hold them.
|
||||
s.execute("home.wrapText", json!({"range": "B2", "on": true})).unwrap();
|
||||
let wrapped_h = s.doc().unwrap().wb.active().unwrap().row_height(1);
|
||||
assert!(wrapped_h > default_h * 2.5, "wrapped four-line text grows the row, got {wrapped_h} vs default {default_h}");
|
||||
// Turning it off again returns the row to one line.
|
||||
s.execute("home.wrapText", json!({"range": "B2", "on": false})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(1), default_h, "wrap off: back to a one-line row");
|
||||
// Entering a break by hand wraps the cell (Alt+Enter), so that row does grow.
|
||||
s.execute("cell.set", json!({"cell": "D4", "input": text})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().row_height(3) > default_h * 2.5, "typed line breaks turn wrap on and grow the row");
|
||||
}
|
||||
|
||||
#[test] // B — Review Focus 1
|
||||
fn open_keeps_custom_height_rows() {
|
||||
let mut s = Session::new();
|
||||
@@ -1362,6 +1391,53 @@ fn open_leaves_plain_workbooks_untouched() {
|
||||
assert!(sh.rows.values().all(|i| i.size.is_none()), "no row gains a height entry");
|
||||
}
|
||||
|
||||
/// Merged cells never auto-fit their rows, like Excel (issue #1605): a wrapped merged
|
||||
/// header keeps the default height instead of growing to its column-A wrap width, while an
|
||||
/// unmerged wrapped cell in the same sheet still fits.
|
||||
#[test]
|
||||
fn autofit_skips_merged_cells() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "This is a long sentence that should wrap across the merged width of five columns"}))
|
||||
.unwrap();
|
||||
s.execute("home.mergeCells", json!({"range": "A1:E1"})).unwrap();
|
||||
s.execute("home.wrapText", json!({"range": "A1:E1", "on": true})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A3", "input": "This is a long sentence that should wrap across the merged width of five columns"}))
|
||||
.unwrap();
|
||||
s.execute("home.wrapText", json!({"range": "A3", "on": true})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert!(sh.rows.get(&0).is_none_or(|i| i.size.is_none()), "merged wrap must not auto-fit row 1, got {:?}", sh.rows.get(&0));
|
||||
assert!(sh.rows.get(&2).is_some_and(|i| i.size.is_some_and(|h| h > 15.0)), "unmerged wrap still fits its row");
|
||||
// The save writes no height for the merged row.
|
||||
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
let bytes = crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("xl/worksheets/sheet1.xml").unwrap(), &mut xml).unwrap();
|
||||
let row1 = xml.split("<row").skip(1).map(|r| format!("<row{r}")).find(|r| r.starts_with("<row r=\"1\"")).unwrap();
|
||||
assert!(!row1.split('>').next().unwrap().contains("ht="), "merged row 1 must be saved without ht, got {row1}");
|
||||
assert!(xml.contains("<mergeCell ref=\"A1:E1\"/>"), "the merge itself is saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_fits_big_font_rows_from_the_font_not_the_sheet_default() {
|
||||
// Excel saves a sheet whose rows hold 16pt text with a 21pt (28px) default row height and no
|
||||
// `ht` on rows of that height. Those rows already fit their font: they open at about 28px,
|
||||
// not scaled up again by 16/11 to 41px.
|
||||
let mut wb = gridcraft_model::Workbook::new();
|
||||
let big = wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |s| s.font.size = 16.0);
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.default_row_height = 28.0;
|
||||
sh.set_value(CellRef::new(4, 0), Value::text("SANTANDER"));
|
||||
sh.set_style(CellRef::new(4, 0), big);
|
||||
let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", json!({"name": "tall-default.xlsx", "base64": crate::io::base64_encode(&bytes)})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.default_row_height, 28.0);
|
||||
let h = sh.row_height(4);
|
||||
assert!((28.0..30.0).contains(&h), "a 16pt row on a 28px-default sheet opens at about 28px, got {h}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_and_structured_refs() {
|
||||
let mut s = s();
|
||||
@@ -3243,6 +3319,38 @@ fn autofill_month_pattern_clamps_to_month_end() {
|
||||
assert_eq!(v(&s, "A6"), ymd(2026, 11, 30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_import_rebuilds_cell_store_bands_and_counts() {
|
||||
// #1008: the JSON reader trusted serialized band keys and the cached length. A file written
|
||||
// with a cell in the wrong band lost it from lookup, and a forged count underflowed on the
|
||||
// next removal.
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A5", "input": "keep"})).unwrap();
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let mut v: serde_json::Value = serde_json::to_value(&**wb).unwrap();
|
||||
{
|
||||
let cells = v["sheets"][0]["cells"].as_object_mut().unwrap();
|
||||
let bands = cells["bands"].as_object_mut().unwrap();
|
||||
let band = bands.remove("0").expect("the cell is in band 0");
|
||||
let cell = band["rows"]["4"]["0"].clone();
|
||||
// Off-grid rows never belong in a sheet, whatever band holds them.
|
||||
bands.insert("1".into(), band);
|
||||
bands.insert("16384".into(), json!({"rows": {"1048576": {"0": cell}}}));
|
||||
cells.insert("len".into(), json!(0));
|
||||
}
|
||||
let bytes = serde_json::to_vec(&v).unwrap();
|
||||
let (back, warnings) = crate::io::open_bytes("book.json", &bytes).unwrap();
|
||||
let sh = back.active().unwrap();
|
||||
let cell = sh.cell(CellRef::parse("A5").unwrap());
|
||||
assert_eq!(cell.map(|c| c.value.clone()), Some(Value::text("keep")), "the cell is found where its row says");
|
||||
assert_eq!(sh.cells.len(), 1, "the count is rebuilt from the cells");
|
||||
assert!(warnings.iter().any(|w| w.contains("outside the 1,048,576")), "{warnings:?}");
|
||||
// Removal stays consistent: the forged count used to underflow.
|
||||
let mut cells = sh.cells.clone();
|
||||
cells.remove(CellRef::parse("A5").unwrap());
|
||||
assert_eq!(cells.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_comments_and_links_survive_a_json_save() {
|
||||
let mut s = s();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! #1969: a wrapped cell's row must fit WORD-wrapped lines, not the character-fit
|
||||
//! estimate (words can't split, so each drawn line ends early and narrow columns
|
||||
//! need more lines than width/estimate suggests).
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s
|
||||
}
|
||||
|
||||
fn row_height(s: &mut Session, row: u32) -> f32 {
|
||||
s.doc().unwrap().wb.active().unwrap().row_height(row)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_row_fits_word_wrapped_lines() {
|
||||
let mut s = session();
|
||||
// Column fits ~10 characters; six-character words mean only one word per line.
|
||||
s.execute("home.columnWidth", json!({"cols": "A:A", "chars": 10})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "aaaaaa bbbbbb cccccc dddddd"})).unwrap();
|
||||
let plain = row_height(&mut s, 0);
|
||||
s.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap();
|
||||
let wrapped = row_height(&mut s, 0);
|
||||
// Word wrap: 4 lines (one word each). The old estimate: ceil(28/10) = 3 lines.
|
||||
// Line height at 11pt is ~19.8, so 4 lines need ~79 vs ~59 for 3.
|
||||
assert!(wrapped >= plain + 3.0 * 19.0, "4 word-wrapped lines must grow the row by ~4 line heights: plain={plain}, wrapped={wrapped}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_repro_nasa_sentence() {
|
||||
let mut s = session();
|
||||
s.execute("home.columnWidth", json!({"cols": "A:A", "chars": 12})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "Since the 1960s, NASA has left hardware across the solar system\u{2013}on the Moon, on Mars, and in deep space."}))
|
||||
.unwrap();
|
||||
s.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap();
|
||||
let h = row_height(&mut s, 0);
|
||||
// Excel shows this at ~10 lines in a 12-char column; the character estimate undercounts.
|
||||
assert!(h >= 9.0 * 19.0, "NASA sentence in a 12-char column needs ~10 lines, got height {h}");
|
||||
}
|
||||
@@ -61,6 +61,28 @@ impl CellStore {
|
||||
self.len += 1;
|
||||
}
|
||||
}
|
||||
/// Puts every cell in the band its row says and recounts the length — serialized band keys
|
||||
/// and the cached count are derived state, and the ones a file carries are not trusted. Cells
|
||||
/// outside the sheet grid are dropped; returns how many (#1008).
|
||||
pub fn rebuild(&mut self) -> usize {
|
||||
let mut out = CellStore::new();
|
||||
let mut dropped = 0;
|
||||
for (_, band) in std::mem::take(&mut self.bands) {
|
||||
for (row, cols) in band.rows.iter() {
|
||||
for (col, cell) in cols.iter() {
|
||||
let at = CellRef::new(*row, *col);
|
||||
if at.is_valid() {
|
||||
out.set(at, cell.clone());
|
||||
} else {
|
||||
dropped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*self = out;
|
||||
dropped
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, c: CellRef) -> Option<Cell> {
|
||||
let key = c.row / BAND;
|
||||
let band = self.bands.get_mut(&key)?;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! 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
|
||||
//! Options, Account); 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, with when each was last changed:
|
||||
//! that is asked of the file system on a thread of its own, never while drawing, and only the
|
||||
@@ -36,10 +36,11 @@ pub enum Page {
|
||||
Print,
|
||||
Export,
|
||||
Options,
|
||||
Account,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
pub const ALL: [Page; 8] = [Page::Home, Page::New, Page::Open, Page::Info, Page::SaveAs, Page::Print, Page::Export, Page::Options];
|
||||
pub const ALL: [Page; 9] = [Page::Home, Page::New, Page::Open, Page::Info, Page::SaveAs, Page::Print, Page::Export, Page::Options, Page::Account];
|
||||
|
||||
/// The id `ui.backstage` and `ui.dialog {"name": "backstage", "page": …}` take.
|
||||
pub fn id(self) -> &'static str {
|
||||
@@ -52,6 +53,7 @@ impl Page {
|
||||
Page::Print => "print",
|
||||
Page::Export => "export",
|
||||
Page::Options => "options",
|
||||
Page::Account => "account",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +73,7 @@ impl Page {
|
||||
Page::Print => "Print",
|
||||
Page::Export => "Export",
|
||||
Page::Options => "Options",
|
||||
Page::Account => "Account",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +87,7 @@ impl Page {
|
||||
Page::Print => Icon::Print,
|
||||
Page::Export => Icon::Share,
|
||||
Page::Options => Icon::Settings,
|
||||
Page::Account => Icon::Info,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,9 +194,12 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
|
||||
out.close = true;
|
||||
}
|
||||
}
|
||||
// Bottom up: Account sits just above Options, as in Excel.
|
||||
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);
|
||||
for page in [Page::Options, Page::Account] {
|
||||
if nav_item(ui, page.icon(), lang.tr(page.label()), bs.page == page, nav_text, nav_sel).clicked() {
|
||||
out.page = Some(page);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -211,6 +218,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
|
||||
Page::Print => print(app, ui, &mut bs, &mut out),
|
||||
Page::Export => export(app, ui, &mut out),
|
||||
Page::Options => options(app, ui),
|
||||
Page::Account => account(app, ui),
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -996,6 +1004,51 @@ fn options(app: &mut SheetApp, ui: &mut Ui) {
|
||||
dim(ui, lang.tr("Calculation settings are saved with the workbook."));
|
||||
}
|
||||
|
||||
/// The platform the app was built for, e.g. "macOS (aarch64)"; the web build reports "Web".
|
||||
fn platform() -> String {
|
||||
let os = match std::env::consts::OS {
|
||||
"macos" => "macOS",
|
||||
"windows" => "Windows",
|
||||
"linux" => "Linux",
|
||||
"freebsd" => "FreeBSD",
|
||||
"openbsd" => "OpenBSD",
|
||||
"netbsd" => "NetBSD",
|
||||
_ if cfg!(target_arch = "wasm32") => return "Web".into(),
|
||||
other => other,
|
||||
};
|
||||
format!("{os} ({})", std::env::consts::ARCH)
|
||||
}
|
||||
|
||||
/// Excel's File › Account: product information and the About window.
|
||||
fn account(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let lang = app.ui.language;
|
||||
section(ui, lang, "Product Information");
|
||||
ui.label(RichText::new("GridCraft").font(theme::ui_bold(16.0)).color(t.text));
|
||||
ui.add_space(8.0);
|
||||
egui::Grid::new("backstage_account").num_columns(2).spacing(vec2(18.0, 8.0)).show(ui, |ui| {
|
||||
for (label, value) in
|
||||
[("Version", env!("CARGO_PKG_VERSION").to_string()), ("Platform", platform()), ("Licence", env!("CARGO_PKG_LICENSE").to_string())]
|
||||
{
|
||||
ui.label(RichText::new(lang.tr(label)).color(t.text_dim));
|
||||
ui.label(value);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(18.0);
|
||||
ui.horizontal(|ui| {
|
||||
if square_button(ui, Icon::Info, lang.tr("About GridCraft")).clicked() {
|
||||
app.open_dialog("about", json!({}));
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(14.0);
|
||||
ui.label(RichText::new(lang.tr("About GridCraft")).font(theme::ui_bold(13.5)).color(t.text));
|
||||
dim(ui, lang.tr("Version, contributors and the AI models that helped"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1077,7 +1130,7 @@ mod tests {
|
||||
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("feedback"), None);
|
||||
assert_eq!(Page::parse(""), None);
|
||||
}
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
||||
("options.autoComplete", "Enable AutoComplete for cell values", "{on?: bool} (toggles without it) → {on}"),
|
||||
("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"),
|
||||
("ui.dialog", "Open Dialog", ""),
|
||||
("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|close}"),
|
||||
("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|account|close}"),
|
||||
(
|
||||
"ui.printSettings",
|
||||
"Print Settings",
|
||||
|
||||
@@ -12,6 +12,12 @@ use std::time::Duration;
|
||||
/// round trip every few seconds is cheap and reacts soon enough after a theme switch.
|
||||
const POLL: Duration = Duration::from_secs(4);
|
||||
|
||||
/// How long one read waits for the portal's reply. Without it a portal that never answers blocks
|
||||
/// the watcher for good, and the theme stops following the desktop. Shorter than [`POLL`]: a read
|
||||
/// that times out drops the connection, and the next poll asks again on a fresh one.
|
||||
#[cfg(target_os = "linux")]
|
||||
const REPLY: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Whether the desktop asks applications to draw dark. `None` when it says nothing, and when
|
||||
/// there is no session bus or no portal to ask (a bare X server, a container), so the caller
|
||||
/// keeps whatever it chose itself.
|
||||
@@ -87,7 +93,7 @@ fn read(link: &mut Link) -> Option<bool> {
|
||||
const SETTINGS: &str = "org.freedesktop.portal.Settings";
|
||||
const SCHEMAS: [(&str, &str); 2] = [("org.gnome.desktop.interface", "color-scheme"), ("org.freedesktop.interface.color-scheme", "color-scheme")];
|
||||
if link.is_none() {
|
||||
*link = Some(zbus::blocking::Connection::session().ok()?);
|
||||
*link = Some(zbus::blocking::connection::Builder::session().ok()?.method_timeout(REPLY).build().ok()?);
|
||||
}
|
||||
let connection = link.as_ref()?;
|
||||
for (schema, key) in SCHEMAS {
|
||||
|
||||
@@ -1578,18 +1578,22 @@ fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) {
|
||||
|
||||
fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) {
|
||||
ui.add_space(8.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
let ok = ui.add(
|
||||
egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE))
|
||||
.fill(crate::theme::Tokens::get(ui.ctx()).accent),
|
||||
);
|
||||
if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) {
|
||||
*confirm = true;
|
||||
}
|
||||
if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() {
|
||||
*open = false;
|
||||
}
|
||||
// A row as tall as the buttons: a right-to-left layout straight in the window takes all the
|
||||
// height it is offered, which stretched every form dialog to the screen's height.
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
let ok = ui.add(
|
||||
egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE))
|
||||
.fill(crate::theme::Tokens::get(ui.ctx()).accent),
|
||||
);
|
||||
if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) {
|
||||
*confirm = true;
|
||||
}
|
||||
if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() {
|
||||
*open = false;
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -958,7 +958,9 @@ pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track:
|
||||
pub(crate) fn wrapped_cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 {
|
||||
let pad_v = 1.5 * z;
|
||||
let y_top = rect.top() + pad_v;
|
||||
let y_bottom = (rect.bottom() - gh - pad_v).max(y_top);
|
||||
// Not clamped to `y_top`: text taller than the row overflows upward (Bottom) or both ways
|
||||
// (Center), and the cell's clip rect trims it, so Middle and Bottom stay distinct from Top.
|
||||
let y_bottom = rect.bottom() - gh - pad_v;
|
||||
match valign {
|
||||
VAlign::Top => y_top,
|
||||
VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0,
|
||||
@@ -1067,7 +1069,9 @@ pub(crate) fn paint_quadrant(
|
||||
let (r0, r1) = rows;
|
||||
let (c0, c1) = cols;
|
||||
let range = RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1));
|
||||
let merges: Vec<RangeRef> = sh.merges.iter().filter(|m| m.intersects(&range)).copied().collect();
|
||||
// Merges are looked up 20 columns beyond the quadrant too: text overflowing in from off-screen cells stops at them.
|
||||
let near = RangeRef::new(CellRef::new(r0, c0.saturating_sub(20)), CellRef::new(r1, c1.saturating_add(20).min(MAX_COLS - 1)));
|
||||
let merges: Vec<RangeRef> = sh.merges.iter().filter(|m| m.intersects(&near)).copied().collect();
|
||||
let in_merge = |c: CellRef| merges.iter().find(|m| m.contains(c)).copied();
|
||||
// Gridlines.
|
||||
if sh.show_gridlines {
|
||||
@@ -1121,8 +1125,25 @@ pub(crate) fn paint_quadrant(
|
||||
}
|
||||
looks.push((anchor, rect, lk, cfl));
|
||||
}
|
||||
// Text cells just outside the quadrant whose text may overflow into it: the nearest non-empty
|
||||
// cell left of `c0` and right of `c1` in each row (within the same frozen / scrolled pane).
|
||||
let mut outside: Vec<(CellRef, Rect, Look, Option<CfLook>)> = Vec::new();
|
||||
let same_pane = |col: u32| (col < geo.fc) == (c0 < geo.fc);
|
||||
for r in r0..=r1 {
|
||||
if sh.row_height(r) == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let left = (c0.saturating_sub(20)..c0).rev().map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n));
|
||||
let right = (c1.saturating_add(1)..=c1.saturating_add(20).min(MAX_COLS - 1)).map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n));
|
||||
for n in [left, right].into_iter().flatten() {
|
||||
if same_pane(n.col) && sh.col_width(n.col) > 0.0 && in_merge(n).is_none() {
|
||||
let (lk, cfl) = look(wb, si, sh, n, cf);
|
||||
outside.push((n, geo.cell_rect(sh, n), lk, cfl));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Text and per-cell decorations.
|
||||
for (c, rect, lk, cfl) in &looks {
|
||||
for (c, rect, lk, cfl) in looks.iter().chain(&outside) {
|
||||
let c = *c;
|
||||
if let Some(picture) = sh.cell_pictures.get(&c) {
|
||||
crate::cell_pictures::paint(p, *rect, picture);
|
||||
@@ -1230,7 +1251,10 @@ pub(crate) fn paint_quadrant(
|
||||
p.with_clip_rect(rect.intersect(p.clip_rect())).galley(pos2(x, y), galley, color);
|
||||
continue;
|
||||
}
|
||||
let first_line = text.lines().next().unwrap_or("").to_string();
|
||||
// Wrap Text off: Excel ignores the line breaks and draws the whole string on one line.
|
||||
let first_line: String = text.chars().filter(|&ch| ch != '\n' && ch != '\r').collect();
|
||||
// The fill position indexes `text`; move it back by the breaks removed before it.
|
||||
let fill_char = fill_char.map(|(ch, pos)| (ch, pos.saturating_sub(text.get(..pos).map_or(0, |s| s.matches(['\n', '\r']).count()))));
|
||||
// Shrink to fit: too-wide content is drawn in a smaller font so it fits the cell (or the
|
||||
// merged area): it neither spills, nor is clipped, nor turns into ####.
|
||||
let small = (st.align.shrink && !rotated && st.align.h != HAlign::Fill && fill_char.is_none())
|
||||
@@ -4060,10 +4084,24 @@ mod tests {
|
||||
assert!(y_top < y_center);
|
||||
assert!(y_center < y_bottom);
|
||||
|
||||
// Overflow case: gh exceeds rect height
|
||||
// Overflow case: gh exceeds rect height; Top still starts inside the cell.
|
||||
let gh_overflow = 60.0;
|
||||
let y_bottom_ovf = wrapped_cell_text_y(VAlign::Bottom, rect, gh_overflow, z);
|
||||
assert!(y_bottom_ovf >= rect.top());
|
||||
let y_top_ovf = wrapped_cell_text_y(VAlign::Top, rect, gh_overflow, z);
|
||||
assert!(y_top_ovf >= rect.top());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_cell_text_y_keeps_vertical_alignment_when_text_is_taller_than_the_row() {
|
||||
// A default-height row holding three wrapped lines.
|
||||
let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0));
|
||||
let (gh, z) = (60.0, 1.0);
|
||||
let y_top = wrapped_cell_text_y(VAlign::Top, rect, gh, z);
|
||||
let y_center = wrapped_cell_text_y(VAlign::Center, rect, gh, z);
|
||||
let y_bottom = wrapped_cell_text_y(VAlign::Bottom, rect, gh, z);
|
||||
// Taller text overflows upward for Bottom and both ways for Center (the cell clips it).
|
||||
assert!((y_top - 1.5).abs() < 0.01, "Top starts at the top edge, got {y_top}");
|
||||
assert!((y_center + gh / 2.0 - rect.center().y).abs() < 0.01, "Center is centred, got {y_center}");
|
||||
assert!((y_bottom + gh - (rect.bottom() - 1.5)).abs() < 0.01, "Bottom ends at the bottom edge, got {y_bottom}");
|
||||
}
|
||||
|
||||
/// The screen box of a galley of `size` drawn at `pos` turned by `angle` about `pos`, as egui
|
||||
|
||||
@@ -257,6 +257,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Export" => "Exportovat",
|
||||
"Close" => "Zavřít",
|
||||
"Options" => "Možnosti",
|
||||
"Account" => "Účet",
|
||||
"Product Information" => "Informace o produktu",
|
||||
"About GridCraft" => "O aplikaci GridCraft",
|
||||
"Version" => "Verze",
|
||||
"Platform" => "Platforma",
|
||||
"Licence" => "Licence",
|
||||
"Blank workbook" => "Prázdný sešit",
|
||||
"Recent" => "Naposledy použité",
|
||||
"No recent workbooks yet." => "Zatím žádné naposledy použité sešity.",
|
||||
|
||||
@@ -259,6 +259,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Export" => "Exportieren",
|
||||
"Close" => "Schließen",
|
||||
"Options" => "Optionen",
|
||||
"Account" => "Konto",
|
||||
"Product Information" => "Produktinformationen",
|
||||
"About GridCraft" => "Info zu GridCraft",
|
||||
"Version" => "Version",
|
||||
"Platform" => "Plattform",
|
||||
"Licence" => "Lizenz",
|
||||
"Version, contributors and the AI models that helped" => "Version, Mitwirkende und die KI-Modelle, die geholfen haben",
|
||||
"Blank workbook" => "Leere Arbeitsmappe",
|
||||
"Recent" => "Zuletzt verwendet",
|
||||
"No recent workbooks yet." => "Noch keine zuletzt verwendeten Arbeitsmappen.",
|
||||
|
||||
@@ -258,6 +258,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Export" => "Exportar",
|
||||
"Close" => "Cerrar",
|
||||
"Options" => "Opciones",
|
||||
"Account" => "Cuenta",
|
||||
"Product Information" => "Información del producto",
|
||||
"About GridCraft" => "Acerca de GridCraft",
|
||||
"Version" => "Versión",
|
||||
"Platform" => "Plataforma",
|
||||
"Licence" => "Licencia",
|
||||
"Version, contributors and the AI models that helped" => "Versión, colaboradores y los modelos de IA que ayudaron",
|
||||
"Blank workbook" => "Libro en blanco",
|
||||
"Recent" => "Recientes",
|
||||
"No recent workbooks yet." => "Todavía no hay libros recientes.",
|
||||
|
||||
@@ -343,6 +343,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Export" => "Exporter",
|
||||
"Close" => "Fermer",
|
||||
"Options" => "Options",
|
||||
"Account" => "Compte",
|
||||
"Product Information" => "Informations sur le produit",
|
||||
"About GridCraft" => "À propos de GridCraft",
|
||||
"Version" => "Version",
|
||||
"Platform" => "Plateforme",
|
||||
"Licence" => "Licence",
|
||||
"Version, contributors and the AI models that helped" => "Version, contributeurs et les modèles d'IA qui ont aidé",
|
||||
"Blank workbook" => "Classeur vide",
|
||||
"Recent" => "Récent",
|
||||
"No recent workbooks yet." => "Aucun classeur récent pour le moment.",
|
||||
|
||||
@@ -344,6 +344,11 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Export", "エクスポート"),
|
||||
("Close", "閉じる"),
|
||||
("Options", "オプション"),
|
||||
("Account", "アカウント"),
|
||||
("Product Information", "製品情報"),
|
||||
("Version", "バージョン"),
|
||||
("Platform", "プラットフォーム"),
|
||||
("Licence", "ライセンス"),
|
||||
("Blank workbook", "空白のブック"),
|
||||
("Recent", "最近使ったアイテム"),
|
||||
("No recent workbooks yet.", "最近使ったブックはまだありません。"),
|
||||
|
||||
@@ -345,6 +345,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Export" => "내보내기",
|
||||
"Close" => "닫기",
|
||||
"Options" => "옵션",
|
||||
"Account" => "계정",
|
||||
"Product Information" => "제품 정보",
|
||||
"About GridCraft" => "GridCraft 정보",
|
||||
"Version" => "버전",
|
||||
"Platform" => "플랫폼",
|
||||
"Licence" => "라이선스",
|
||||
"Version, contributors and the AI models that helped" => "버전, 기여자 및 도움을 준 AI 모델",
|
||||
"Blank workbook" => "새 통합 문서",
|
||||
"Recent" => "최근 항목",
|
||||
"No recent workbooks yet." => "최근 통합 문서가 아직 없습니다.",
|
||||
|
||||
@@ -341,6 +341,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Export" => "Exportar",
|
||||
"Close" => "Fechar",
|
||||
"Options" => "Opções",
|
||||
"Account" => "Conta",
|
||||
"Product Information" => "Informações do produto",
|
||||
"Version" => "Versão",
|
||||
"Platform" => "Plataforma",
|
||||
"Licence" => "Licença",
|
||||
"Blank workbook" => "Pasta de trabalho em branco",
|
||||
"Recent" => "Recentes",
|
||||
"No recent workbooks yet." => "Ainda não há pastas de trabalho recentes.",
|
||||
|
||||
@@ -345,6 +345,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Export" => "Экспорт",
|
||||
"Close" => "Закрыть",
|
||||
"Options" => "Параметры",
|
||||
"Account" => "Учетная запись",
|
||||
"Product Information" => "Сведения о продукте",
|
||||
"About GridCraft" => "О программе GridCraft",
|
||||
"Version" => "Версия",
|
||||
"Platform" => "Платформа",
|
||||
"Licence" => "Лицензия",
|
||||
"Version, contributors and the AI models that helped" => "Версия, участники и модели ИИ, которые помогали",
|
||||
"Blank workbook" => "Пустая книга",
|
||||
"Recent" => "Последние",
|
||||
"No recent workbooks yet." => "Недавних книг пока нет.",
|
||||
|
||||
@@ -337,6 +337,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Export" => "Izvezi",
|
||||
"Close" => "Zatvori",
|
||||
"Options" => "Opcije",
|
||||
"Account" => "Nalog",
|
||||
"Product Information" => "Informacije o proizvodu",
|
||||
"About GridCraft" => "O programu GridCraft",
|
||||
"Version" => "Verzija",
|
||||
"Platform" => "Platforma",
|
||||
"Licence" => "Licenca",
|
||||
"Version, contributors and the AI models that helped" => "Verzija, saradnici i AI modeli koji su pomogli",
|
||||
"Blank workbook" => "Prazna radna sveska",
|
||||
"Recent" => "Nedavno",
|
||||
"No recent workbooks yet." => "Još nema nedavnih radnih svezaka.",
|
||||
|
||||
@@ -343,6 +343,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Export" => "导出",
|
||||
"Close" => "关闭",
|
||||
"Options" => "选项",
|
||||
"Account" => "账户",
|
||||
"Product Information" => "产品信息",
|
||||
"About GridCraft" => "关于 GridCraft",
|
||||
"Version" => "版本",
|
||||
"Platform" => "平台",
|
||||
"Licence" => "许可证",
|
||||
"Version, contributors and the AI models that helped" => "版本、贡献者以及参与协助的 AI 模型",
|
||||
"Blank workbook" => "空白工作簿",
|
||||
"Recent" => "最近",
|
||||
"No recent workbooks yet." => "还没有最近使用的工作簿。",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Form dialogs fit their content: OK and Cancel sit right under the last field, not halfway down
|
||||
//! a window stretched to the screen's height.
|
||||
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn ok_and_cancel_sit_under_the_last_field() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 1000.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(s, Default::default()),
|
||||
);
|
||||
h.state_mut().open_dialog("cfQuick", json!({"type": "cellIs", "operator": "greaterThan", "title": "Greater Than…"}));
|
||||
h.run_steps(4);
|
||||
let last = h.get_by_label("Red Border").rect();
|
||||
let ok = h.get_by_label_contains("OK").rect();
|
||||
let gap = ok.top() - last.bottom();
|
||||
assert!((0.0..40.0).contains(&gap), "OK is {gap} points under the last option");
|
||||
}
|
||||
@@ -122,7 +122,7 @@ fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() {
|
||||
h.run_steps(2);
|
||||
assert_eq!(page(&h), Some(id));
|
||||
}
|
||||
assert!(h.state_mut().run("ui.backstage", serde_json::json!({"page": "account"})).is_err());
|
||||
assert!(h.state_mut().run("ui.backstage", serde_json::json!({"page": "feedback"})).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.
|
||||
@@ -161,6 +161,20 @@ fn translated_options_headings_show_no_context_prefix() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_account_shows_the_version_and_opens_about() {
|
||||
let mut h = harness(Services::default());
|
||||
choose(&mut h, "Account");
|
||||
assert_eq!(page(&h), Some("account"));
|
||||
assert!(h.state().dialog.is_none());
|
||||
// The About dialog also shows the version; it must not be open yet.
|
||||
h.get_by_label(env!("CARGO_PKG_VERSION"));
|
||||
h.get_by_label(env!("CARGO_PKG_LICENSE"));
|
||||
click(&mut h, "About GridCraft");
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("about"));
|
||||
assert_eq!(page(&h), Some("account"), "About opens over the File view, like Protect Workbook on Info");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_as_pdf_is_delivered_as_a_download() {
|
||||
let files = Arc::new(Mutex::new(Vec::<(String, Vec<u8>)>::new()));
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Line breaks with Wrap Text off (#1604): Excel ignores them and draws the whole string on one line.
|
||||
|
||||
use egui::Shape;
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, gridcraft_ui_egui::SheetApp>;
|
||||
|
||||
fn harness() -> Harness {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "Active\n\nUnits: LF 2x10\r\nBand: 60-20000 Hz"})).unwrap();
|
||||
// Typing a break turns Wrap Text on; a file can still store the text with it off.
|
||||
s.execute("home.formatCells", json!({"range": "A1", "style": {"align": {"wrap": false}}})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A3", "input": "Wrapped\nText"})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "A3", "style": {"align": {"wrap": true}}})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "H20"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut gridcraft_ui_egui::SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
gridcraft_ui_egui::SheetApp::new(s, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn painted(h: &Harness, text: &str) -> bool {
|
||||
h.output().shapes.iter().any(|s| matches!(&s.shape, Shape::Text(t) if t.galley.job.text == text))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwrapped_cell_draws_every_line_on_one_line() {
|
||||
let h = harness();
|
||||
assert!(painted(&h, "ActiveUnits: LF 2x10Band: 60-20000 Hz"), "A1 draws the whole string without its breaks");
|
||||
assert!(!painted(&h, "Active"), "A1 is not cut at its first break");
|
||||
// Wrap Text on keeps the breaks.
|
||||
assert!(painted(&h, "Wrapped\nText"));
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Overflowing text stays visible in its empty neighbours when its own cell is scrolled out (#1613).
|
||||
|
||||
use egui::{Rect, Shape};
|
||||
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>;
|
||||
|
||||
const LEFT: &str = "This is a very long left-aligned text that overflows into many empty neighbouring cells";
|
||||
const RIGHT: &str = "This is a very long right-aligned text that overflows into many empty neighbouring cells";
|
||||
|
||||
fn harness(setup: impl FnOnce(&mut Session)) -> Harness {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
setup(&mut s);
|
||||
s.execute("selection.set", json!({"range": "A20"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(s, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
/// Scroll so that `col` is the first column of the scrolled area.
|
||||
fn scroll_to_col(h: &mut Harness, col: u32) {
|
||||
let app = h.state_mut();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let fc = sheet.freeze.map(|f| f.1).unwrap_or(0);
|
||||
let x = (sheet.col_left(col) - sheet.col_left(fc)) as f32;
|
||||
app.view_mut().unwrap().scroll.x = x;
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
/// Clip rects of the painted text shapes whose text is `text`.
|
||||
fn text_clips(h: &Harness, text: &str) -> Vec<Rect> {
|
||||
h.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|s| match &s.shape {
|
||||
Shape::Text(t) if t.galley.job.text == text => Some(s.clip_rect),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn cell_rect(h: &Harness, cell: &str) -> Rect {
|
||||
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);
|
||||
geo.cell_rect(sheet, CellRef::parse(cell).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_text_stays_visible_when_source_cell_scrolls_out_left() {
|
||||
let mut h = harness(|s| {
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": LEFT})).unwrap();
|
||||
});
|
||||
assert!(!text_clips(&h, LEFT).is_empty(), "B1 is drawn before scrolling");
|
||||
scroll_to_col(&mut h, 2);
|
||||
let c1 = cell_rect(&h, "C1");
|
||||
let clips = text_clips(&h, LEFT);
|
||||
assert!(clips.iter().any(|r| r.intersects(c1.shrink(1.0))), "B1's overflow is still drawn into C1 after scrolling: {clips:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_aligned_overflow_stays_visible_when_source_cell_is_right_of_view() {
|
||||
let mut h = harness(|s| {
|
||||
s.execute("cell.set", json!({"cell": "Z1", "input": RIGHT})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "Z1", "style": {"align": {"h": "Right"}}})).unwrap();
|
||||
});
|
||||
// Scroll so that Z1 starts just past the right edge of the cell area.
|
||||
{
|
||||
let app = h.state_mut();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
|
||||
let x = sheet.col_left(25) as f32 - (geo.cells.width() - geo.frozen_w) / geo.z - 2.0;
|
||||
app.view_mut().unwrap().scroll.x = x;
|
||||
}
|
||||
h.run_steps(4);
|
||||
let (y1, z1, cells) = {
|
||||
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);
|
||||
(geo.cell_rect(sheet, CellRef::parse("Y1").unwrap()), geo.cell_rect(sheet, CellRef::parse("Z1").unwrap()), geo.cells)
|
||||
};
|
||||
assert!(z1.left() >= cells.right(), "Z1 is out of view on the right ({z1:?} vs {cells:?})");
|
||||
let visible = y1.intersect(cells).shrink(1.0);
|
||||
let clips = text_clips(&h, RIGHT);
|
||||
assert!(clips.iter().any(|r| r.intersects(visible)), "Z1's overflow is drawn into Y1: {clips:?} vs {visible:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolled_overflow_does_not_draw_over_frozen_columns() {
|
||||
let mut h = harness(|s| {
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": LEFT})).unwrap();
|
||||
s.execute("view.freezePanes", json!({"cell": "B2"})).unwrap();
|
||||
});
|
||||
scroll_to_col(&mut h, 4);
|
||||
let a1 = cell_rect(&h, "A1");
|
||||
let e1 = cell_rect(&h, "E1");
|
||||
let clips = text_clips(&h, LEFT);
|
||||
assert!(clips.iter().any(|r| r.intersects(e1.shrink(1.0))), "C1's overflow reaches E1: {clips:?}");
|
||||
assert!(clips.iter().all(|r| !r.intersects(a1.shrink(1.0))), "C1's overflow stays out of frozen column A: {clips:?}");
|
||||
}
|
||||
Reference in New Issue
Block a user