Integration: track t09

# Conflicts:
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/i18n.rs
#	crates/ui-egui/src/i18n/pt.rs
#	docs/file-format-parity.md
#	docs/target-app-parity.md
#	docs/ui-parity.md
This commit is contained in:
aiongg
2026-10-22 03:04:21 +00:00
37 changed files with 1356 additions and 112 deletions
+58
View File
@@ -330,6 +330,21 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<S
}
// Move whole lines (cells with formulas adjusted for relative references).
let lanes: Vec<u32> = if by_cols { (body.start.row..=body.end.row).collect() } else { (body.start.col..=body.end.col).collect() };
// Notes and hyperlinks hang off a cell, not a row, so they travel with the row they
// describe — the way cell pictures and row heights do. Collect them before inserting,
// so a swap can't resurrect one from a destination that is also a source.
let side: Vec<(CellRef, Comment)> = order
.iter()
.enumerate()
.flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k)))
.filter_map(|(src_i, dst_i, k)| sh.comments.get(&line(src_i, k)).map(|cm| (line(dst_i, k), cm.clone())))
.collect();
let links: Vec<(CellRef, Hyperlink)> = order
.iter()
.enumerate()
.flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k)))
.filter_map(|(src_i, dst_i, k)| sh.hyperlinks.get(&line(src_i, k)).map(|h| (line(dst_i, k), h.clone())))
.collect();
let mut moved: Vec<(CellRef, Option<Cell>)> = Vec::with_capacity(body.count() as usize);
let mut pictures = Vec::new();
for (dst_i, &src_i) in order.iter().enumerate() {
@@ -389,6 +404,18 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<S
}
}
}
// Every cell of the body is the destination of exactly one source cell, so clearing the
// body and inserting the permuted entries is the same permutation the cells went through.
for c in body.iter() {
shm.comments.remove(&c);
shm.hyperlinks.remove(&c);
}
for (at, cm) in side {
shm.comments.insert(at, cm);
}
for (at, h) in links {
shm.hyperlinks.insert(at, h);
}
shm.cell_pictures.extend(pictures);
for (i, h) in heights.into_iter().enumerate() {
let row = body.start.row + i as u32;
@@ -825,6 +852,24 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
.filter_map(move |col| sh.cell_pictures.get(&CellRef::new(*row, col)).map(|p| (CellRef::new(start + i as u32, col), p.clone())))
})
.collect();
// Rows a filter keeps are compacted to `start + i`, so the notes and hyperlinks that
// describe them move with them; the ones on removed rows are dropped with the row.
let notes: Vec<_> = keep
.iter()
.enumerate()
.flat_map(|(i, row)| {
(r.start.col..=r.end.col)
.filter_map(move |col| sh.comments.get(&CellRef::new(*row, col)).map(|cm| (CellRef::new(start + i as u32, col), cm.clone())))
})
.collect();
let links: Vec<_> = keep
.iter()
.enumerate()
.flat_map(|(i, row)| {
(r.start.col..=r.end.col)
.filter_map(move |col| sh.hyperlinks.get(&CellRef::new(*row, col)).map(|h| (CellRef::new(start + i as u32, col), h.clone())))
})
.collect();
let rows: Vec<Vec<Option<Cell>>> =
keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect();
let shm = cx.sheet_mut(sheet)?;
@@ -833,6 +878,13 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
shm.remove_cell(CellRef::new(row, c));
}
}
for row in start..=r.end.row {
for c in r.start.col..=r.end.col {
let at = CellRef::new(row, c);
shm.comments.remove(&at);
shm.hyperlinks.remove(&at);
}
}
for (i, cells) in rows.into_iter().enumerate() {
for (j, cell) in cells.into_iter().enumerate() {
if let Some(cell) = cell {
@@ -841,6 +893,12 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
}
}
shm.cell_pictures.extend(pictures);
for (at, cm) in notes {
shm.comments.insert(at, cm);
}
for (at, h) in links {
shm.hyperlinks.insert(at, h);
}
for c in RangeRef::new(CellRef::new(start, r.start.col), r.end).iter() {
cx.changed.push((sheet, c));
}
+102
View File
@@ -1003,6 +1003,62 @@ fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option<crate::
pg.restore();
}
struct PendingPdfLink {
page: usize,
sheet: usize,
rect: (f32, f32, f32, f32),
target: String,
}
struct PrintedBlock {
page: usize,
sheet: usize,
rows: (u32, u32),
cols: (u32, u32),
x: f32,
y: f32,
k: f32,
}
/// PDF annotations are not affected by graphics clipping, so intersect each cell with its page
/// block before adding a clickable rectangle. A repeated print-title cell may appear on several
/// pages and receives one annotation on each of them.
fn draw_block_links(pg: &mut Page, b: &Block, page: usize, pending: &mut Vec<PendingPdfLink>) {
let range = b.range();
let (Some(&bx), Some(&by), Some(&right), Some(&bottom)) = (b.xs.first(), b.ys.first(), b.xs.last(), b.ys.last()) else { return };
for (cell, link) in b.sh.hyperlinks.range(range.start..=range.end) {
if !range.contains(*cell) {
continue;
}
let cell_range = b.sh.merges.iter().find(|m| m.start == *cell).copied().unwrap_or(RangeRef::cell(*cell));
let (x, y, w, h) = b.rect(cell_range);
let (x0, y0, x1, y1) = (x.max(bx), y.max(by), (x + w).min(right), (y + h).min(bottom));
if x1 <= x0 || y1 <= y0 {
continue;
}
let rect = (x0, y0, x1 - x0, y1 - y0);
match crate::links::classify(&link.target) {
crate::links::LinkKind::External(uri) => pg.link_uri(rect.0, rect.1, rect.2, rect.3, &uri),
crate::links::LinkKind::Internal(target) if !link.external => {
pending.push(PendingPdfLink { page, sheet: b.si, rect, target });
}
_ => {}
}
}
}
fn printed_target(wb: &Workbook, blocks: &[PrintedBlock], source_sheet: usize, target: &str) -> Option<(usize, f32, f32)> {
let target = target.trim().trim_start_matches('#').trim_start_matches('=');
let (sheet_name, range) = split_sheet(target);
let sheet = sheet_name.map_or(Some(source_sheet), |name| wb.sheet_index(&name))?;
let cell = RangeRef::parse(&range.replace('$', ""))?.start;
let sh = wb.sheet(sheet)?;
let block = blocks.iter().find(|b| b.sheet == sheet && (b.rows.0..=b.rows.1).contains(&cell.row) && (b.cols.0..=b.cols.1).contains(&cell.col))?;
let x = block.x + (sh.col_left(cell.col) - sh.col_left(block.cols.0)) as f32 * block.k;
let y = block.y + (sh.row_top(cell.row) - sh.row_top(block.rows.0)) as f32 * block.k;
Some((block.page, x, y))
}
/// Cell pictures fit the current cell (or merged-cell) rectangle without changing aspect ratio.
fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32, f32), k: f32) {
let (x, y, w, h) = rect;
@@ -1470,6 +1526,8 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
let total: usize = layouts.iter().map(|l| l.pages.len()).sum();
let (date, time) = hf_date_time(wb);
let mut page_no = 0usize;
let mut pending_links = Vec::new();
let mut printed_blocks = Vec::new();
for lay in layouts {
let Some(sh) = wb.sheet(lay.sheet) else { continue };
let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) };
@@ -1484,6 +1542,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
let content_h = (lay.heading_h + rsegs.iter().map(|s| seg_size(sh, *s, true)).sum::<f32>()) * k;
let ox = lay.margins[0] + if lay.settings.center_h { ((aw - content_w) / 2.0).max(0.0) } else { 0.0 };
let oy = lay.margins[2] + if lay.settings.center_v { ((ah - content_h) / 2.0).max(0.0) } else { 0.0 };
let page_index = doc.page_count();
let pg = doc.add_page(lay.page_w, lay.page_h);
let mut y = oy + lay.heading_h * k;
let mut body: Option<Block> = None;
@@ -1492,6 +1551,8 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
for (csi, cs) in csegs.iter().enumerate() {
let b = make_block(wb, lay.sheet, sh, *rs, *cs, x, y, lay);
draw_block(pg, &b, lay.settings.gridlines, &mut cf);
draw_block_links(pg, &b, page_index, &mut pending_links);
printed_blocks.push(PrintedBlock { page: page_index, sheet: lay.sheet, rows: *rs, cols: *cs, x, y, k });
if lay.settings.headings {
draw_headings(pg, &b, rsi == 0, csi == 0, (ox, oy), (lay.heading_w * k, lay.heading_h * k));
}
@@ -1522,6 +1583,14 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
draw_header_footer(pg, lay, lay.settings.hf_page(pi == 0, page_no), &cx);
}
}
for link in pending_links {
if let Some((target_page, x, y)) = printed_target(wb, &printed_blocks, link.sheet, &link.target)
&& let Some(page) = doc.page_mut(link.page)
{
let (lx, ly, w, h) = link.rect;
page.link_page(lx, ly, w, h, target_page, x, y);
}
}
let n = doc.page_count();
(doc.finish(), n)
}
@@ -1767,6 +1836,29 @@ mod tests {
hay.windows(needle.len()).any(|w| w == needle.as_bytes())
}
#[test]
fn pdf_export_keeps_safe_links_on_their_printed_pages() {
let mut s = s();
s.execute("insert.link", json!({"cell": "A1", "target": "https://example.org/report"})).unwrap();
s.execute("insert.link", json!({"cell": "A2", "target": "Sheet1!A60"})).unwrap();
s.execute("insert.link", json!({"cell": "A3", "target": "file:///private/report"})).unwrap();
s.execute("cell.set", json!({"cell": "A60", "input": "Destination"})).unwrap();
let (pdf, pages) = pdf_of(&mut s, json!({}));
assert_eq!(pages, 2);
let text = String::from_utf8_lossy(&pdf);
assert_eq!(text.matches("/Subtype /Link").count(), 2);
assert!(text.contains("/URI (https://example.org/report)"));
assert!(text.contains("/Dest [") && text.contains("/XYZ "));
assert!(!text.contains("/URI (file:"));
// A link to a cell omitted by the selected print range must not point to a nonexistent
// destination. The external link in the selected range remains clickable.
let (cropped, _) = pdf_of(&mut s, json!({"range": "A1:A3"}));
let cropped = String::from_utf8_lossy(&cropped);
assert_eq!(cropped.matches("/Subtype /Link").count(), 1);
assert!(!cropped.contains("/Dest ["));
}
#[test]
fn header_sections_split_join_and_field_names() {
let spec = "&L&\"Arial,Bold\"Sales &&Co&CPage &P of &N&R&D";
@@ -1902,6 +1994,16 @@ mod tests {
assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj"));
}
#[test]
fn pdf_prints_cjk_text_instead_of_question_marks() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["签字清单", 2]]})).unwrap();
let (pdf, _) = pdf_of(&mut s, json!({}));
assert!(!contains(&pdf, "(????) Tj"), "CJK cell printed as (????) Tj");
assert!(contains(&pdf, "<7B7E5B576E055355> Tj"));
assert!(contains(&pdf, "(2) Tj"));
}
#[test]
fn pdf_contains_text_and_header_codes_expand() {
let mut s = s();
+22 -4
View File
@@ -265,13 +265,31 @@ fn print_area(s: &mut Session, p: &Json) -> Result<Json> {
}
fn print_titles(s: &mut Session, p: &Json) -> Result<Json> {
let rows = str_param(p, "rows").and_then(RangeRef::parse).map(|r| (r.start.row, r.end.row));
let cols = str_param(p, "cols").and_then(RangeRef::parse).map(|r| (r.start.col, r.end.col));
// An absent key leaves that axis alone, and an empty or null value clears it — the same
// reading `pageLayout.pageSetup` gives `titleRows`/`titleCols`. Assigning `None` for a
// key that was never sent used to wipe the other axis.
let axis = |key: &str, is_cols: bool| -> Result<Option<Option<(u32, u32)>>> {
match p.get(key) {
None => Ok(None),
Some(Json::Null) => Ok(Some(None)),
Some(Json::String(t)) if t.trim().is_empty() => Ok(Some(None)),
Some(Json::String(t)) => match RangeRef::parse(t) {
Some(r) => Ok(Some(Some(if is_cols { (r.start.col, r.end.col) } else { (r.start.row, r.end.row) }))),
None => Err(bad("pageLayout.printTitles", format!("`{key}` is not a range: `{t}`"))),
},
Some(_) => Err(bad("pageLayout.printTitles", format!("`{key}` must be text like \"1:2\" or \"A:A\""))),
}
};
let (rows, cols) = (axis("rows", false)?, axis("cols", true)?);
edit(s, |cx| {
let si = cx.sheet_index();
let pr = &mut cx.sheet_mut(si)?.print;
pr.title_rows = rows;
pr.title_cols = cols;
if let Some(t) = rows {
pr.title_rows = t;
}
if let Some(t) = cols {
pr.title_cols = t;
}
Ok(Json::Null)
})
}
+84 -32
View File
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Advanced…",
["Data", "Sort & Filter"],
None,
"{range? (list with headers; default current region), criteria: \"H1:I3\" (header row of field names; rows OR'ed, columns AND'ed; \">100\", \"=abc\", \"a*\", \"<>\"; blank-headed formula cells are computed criteria for the first data row), copyTo?: \"K1\" (else filter in place by hiding rows), unique?: bool}",
"{range? (list with headers; default current region), criteria?: \"H1:I3\" (omitted/blank matches all rows; header row of field names; rows OR'ed, columns AND'ed; \">100\", \"=abc\", \"a*\", \"<>\"; blank-headed formula cells are computed criteria for the first data row), copyTo?: \"K1\" (else filter in place by hiding rows), unique?: bool}",
has_doc,
advanced_filter
),
@@ -802,41 +802,45 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
(d.wb.active_sheet, crate::selection::current_region(sh, d.selection.active))
}
};
let (csi, crit) = range_arg(s, p, "criteria", CMD)?;
let criteria = str_param(p, "criteria").filter(|t| !t.trim().is_empty()).map(|t| parse_range(&d.wb, t, CMD)).transpose()?;
if list.height() < 1 || list.count() > 50_000_000 {
return Err(bad(CMD, "the list range is too large"));
}
if crit.width() > 1024 || crit.height() > 10_000 {
if let Some((_, crit)) = criteria
&& (crit.width() > 1024 || crit.height() > 10_000)
{
return Err(bad(CMD, "the criteria range is too large"));
}
let wb = &d.wb;
let sh = wb.sheet(si).ok_or(EngineError::NoDocument)?;
let csh = wb.sheet(csi).ok_or(EngineError::NoDocument)?;
let headers: Vec<String> =
(list.start.col..=list.end.col).map(|c| crate::display::cell_text(wb, sh, CellRef::new(list.start.row, c)).trim().to_lowercase()).collect();
let first_data = list.start.row + 1;
// Criteria rows → conditions.
let mut rows: Vec<Vec<Cond>> = Vec::new();
for cr in crit.start.row + 1..=crit.end.row {
let mut conds = Vec::new();
for cc in crit.start.col..=crit.end.col {
let at = CellRef::new(cr, cc);
let cell = csh.cell(at);
let v = csh.value(at);
let formula = cell.and_then(|c| c.formula.as_ref());
if v.is_empty() && formula.is_none() {
continue;
}
let head = crate::display::cell_text(wb, csh, CellRef::new(crit.start.row, cc)).trim().to_lowercase();
match headers.iter().position(|h| !head.is_empty() && *h == head) {
Some(off) => conds.push(Cond::Compare(off as u32, v)),
None => match formula.and_then(|f| f.expr()) {
Some(e) => conds.push(Cond::Computed(e, at)),
None => return Err(EngineError::Other(format!("The criteria label `{head}` doesn't match a column of the list."))),
},
if let Some((csi, crit)) = criteria {
let csh = wb.sheet(csi).ok_or(EngineError::NoDocument)?;
for cr in crit.start.row + 1..=crit.end.row {
let mut conds = Vec::new();
for cc in crit.start.col..=crit.end.col {
let at = CellRef::new(cr, cc);
let cell = csh.cell(at);
let v = csh.value(at);
let formula = cell.and_then(|c| c.formula.as_ref());
if v.is_empty() && formula.is_none() {
continue;
}
let head = crate::display::cell_text(wb, csh, CellRef::new(crit.start.row, cc)).trim().to_lowercase();
match headers.iter().position(|h| !head.is_empty() && *h == head) {
Some(off) => conds.push(Cond::Compare(off as u32, v)),
None => match formula.and_then(|f| f.expr()) {
Some(e) => conds.push(Cond::Computed(e, at)),
None => return Err(EngineError::Other(format!("The criteria label `{head}` doesn't match a column of the list."))),
},
}
}
rows.push(conds);
}
rows.push(conds);
}
let matches_row = |row: u32| -> bool {
if rows.is_empty() {
@@ -852,7 +856,7 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
}
Cond::Computed(e, at) => {
let shifted = gridcraft_formula::adjust::shift_relative(e.clone(), row as i64 - first_data as i64, 0);
let v = gridcraft_calc::recalc::evaluate_expr(wb, csi, *at, &shifted);
let v = gridcraft_calc::recalc::evaluate_expr(wb, criteria.map(|(si, _)| si).unwrap_or(si), *at, &shifted);
matches!(v.scalar().to_bool(), Ok(true))
}
})
@@ -874,28 +878,30 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(t) = str_param(p, "copyTo") {
let (dsi, dest) = parse_range(wb, t, CMD)?;
let dest = dest.start;
let extract = RangeRef::new(dest, dest.offset_clamped(list.height() as i64 - 1, list.width() as i64 - 1));
if dsi == si && extract.intersects(&list) {
return Err(EngineError::Other("The extract range can't overlap the list range.".into()));
}
let mut rows_out: Vec<u32> = vec![list.start.row];
rows_out.extend(keep.iter().filter(|(_, k)| *k).map(|(r, _)| *r));
let end =
dest.offset(rows_out.len() as i64 - 1, list.width() as i64 - 1).ok_or_else(|| bad(CMD, "the extract range exceeds the worksheet"))?;
let out_range = RangeRef::new(dest, end);
if dsi == si && out_range.intersects(&list) {
return Err(EngineError::Other("The extract range can't overlap the list range.".into()));
}
let cells: Vec<(CellRef, Cell)> = rows_out
.iter()
.enumerate()
.flat_map(|(i, row)| {
(list.start.col..=list.end.col).filter_map(move |c| {
(list.start.col..=list.end.col).map(move |c| {
let src = CellRef::new(*row, c);
let to = dest.offset(i as i64, (c - list.start.col) as i64)?;
// The full output footprint was checked above.
let to = CellRef::new(dest.row + i as u32, dest.col + c - list.start.col);
let style = sh.style_id(src);
Some((to, Cell { value: sh.value(src), formula: None, style }))
(to, Cell { value: sh.value(src), formula: None, style })
})
})
.collect();
let out_range = RangeRef::new(dest, dest.offset_clamped(rows_out.len() as i64 - 1, list.width() as i64 - 1));
edit(s, |cx| {
let shm = cx.sheet_mut(dsi)?;
let old: Vec<CellRef> = shm.cells.iter_range(extract).map(|(c, _)| c).collect();
let old: Vec<CellRef> = shm.cells.iter_range(out_range).map(|(c, _)| c).collect();
for c in &old {
shm.remove_cell(*c);
}
@@ -1335,6 +1341,52 @@ mod tests {
assert!(s.execute("data.advancedFilter", json!({"range": "A1:B6", "criteria": "H1:I3", "copyTo": "B2"})).is_err());
}
#[test]
fn advanced_filter_copy_preserves_cells_beyond_result() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["A", 1], ["B", 2], ["C", 3]]})).unwrap();
s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["B"]]})).unwrap();
s.execute("cell.set", json!({"cell": "K4", "input": "Keep me"})).unwrap();
let r = s.execute("data.advancedFilter", json!({"range": "A1:B4", "criteria": "H1:H2", "copyTo": "K1"})).unwrap();
assert_eq!(r["range"], "K1:L2");
assert_eq!(v(&s, "K2"), Value::from("B"));
assert_eq!(v(&s, "K4"), Value::from("Keep me"));
}
#[test]
fn advanced_filter_copy_rejects_overflow_without_mutation() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["A", 1]]})).unwrap();
s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["A"]]})).unwrap();
for dest in ["XFD1", "K1048576"] {
s.execute("cell.set", json!({"cell": dest, "input": "Keep me"})).unwrap();
assert!(s.execute("data.advancedFilter", json!({"range": "A1:B2", "criteria": "H1:H2", "copyTo": dest})).is_err());
assert_eq!(v(&s, dest), Value::from("Keep me"));
}
}
#[test]
fn advanced_filter_copy_allows_output_above_source_when_only_old_clear_area_overlaps() {
let mut s = s();
s.execute("range.setValues", json!({"range": "C4", "values": [["Name", "Score"], ["A", 1], ["B", 2], ["C", 3]]})).unwrap();
s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["A"]]})).unwrap();
let r = s.execute("data.advancedFilter", json!({"range": "C4:D7", "criteria": "H1:H2", "copyTo": "B1"})).unwrap();
assert_eq!(r["range"], "B1:C2");
assert_eq!(v(&s, "C4"), Value::from("Name"));
assert_eq!(v(&s, "C2"), Value::Number(1.0));
}
#[test]
fn advanced_filter_unique_without_criteria() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [["Name"], ["A"], ["A"], ["B"]]})).unwrap();
let r = s.execute("data.advancedFilter", json!({"range": "A1:A4", "copyTo": "C1", "unique": true})).unwrap();
assert_eq!(r["matched"], 2);
assert_eq!(r["range"], "C1:C3");
assert_eq!(v(&s, "C2"), Value::from("A"));
assert_eq!(v(&s, "C3"), Value::from("B"));
}
#[test]
fn criteria_semantics() {
use super::criterion_matches as m;
+68
View File
@@ -3243,6 +3243,74 @@ fn a_cut_moves_the_sources_notes_hyperlinks_and_merges() {
assert!(v(&s, "A1").is_empty() && v(&s, "B1").is_empty());
}
/// Page Layout ▸ Print Titles: naming one axis must not clear the other. `print_titles` read
/// both `rows` and `cols` into `Option`s and assigned both unconditionally, so
/// `{"rows":"2:2"}` silently dropped the repeated columns from every printed page.
#[test]
fn print_titles_keeps_the_axis_it_is_not_given() {
let mut s = s();
s.execute("pageLayout.printTitles", json!({"rows": "1:1", "cols": "A:A"})).unwrap();
let titles = |s: &Session| {
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
(sh.print.title_rows, sh.print.title_cols)
};
assert_eq!(titles(&s), (Some((0, 0)), Some((0, 0))));
// Only rows: the columns stay.
s.execute("pageLayout.printTitles", json!({"rows": "2:2"})).unwrap();
assert_eq!(titles(&s), (Some((1, 1)), Some((0, 0))), "rows alone must not clear the columns");
// Only columns: the rows stay.
s.execute("pageLayout.printTitles", json!({"cols": "B:B"})).unwrap();
assert_eq!(titles(&s), (Some((1, 1)), Some((1, 1))), "columns alone must not clear the rows");
// An empty value clears that axis on purpose.
s.execute("pageLayout.printTitles", json!({"cols": ""})).unwrap();
assert_eq!(titles(&s), (Some((1, 1)), None));
// A bad range is an error, not a silent wipe.
assert!(s.execute("pageLayout.printTitles", json!({"rows": "not a range"})).is_err());
assert_eq!(titles(&s), (Some((1, 1)), None));
}
/// Sorting and removing duplicates rewrite the rows of a range. The cells, pictures, row heights
/// and anchors travel with them, but the notes and hyperlinks hung off their old cells: a note
/// describing one record ended up annotating whatever landed on that row, and on a removed row it
/// annotated blank cells.
#[test]
fn sort_and_remove_duplicates_move_the_rows_notes_and_links() {
fn setup(values: [&str; 5]) -> Session {
let mut s = s();
for (row, text) in values.iter().enumerate() {
s.execute("cell.set", json!({"cell": format!("A{}", row + 1), "input": text})).unwrap();
}
s.execute("review.newNote", json!({"cell": "B5", "text": "late", "author": "Example"})).unwrap();
s.execute("insert.link", json!({"cell": "B4", "target": "https://example.com/4"})).unwrap();
s
}
let notes_and_links = |s: &Session| {
let sh = s.doc().unwrap().wb.active().unwrap().clone();
let cells = ["B2", "B3", "B4", "B5"].map(|c| CellRef::parse(c).unwrap());
(cells.map(|c| sh.comments.get(&c).map(|m| m.text.clone())), cells.map(|c| sh.hyperlinks.get(&c).map(|h| h.target.clone())))
};
// Sort ascending with unique keys d,a,e,b,c: the order becomes a(2), b(4), c(5), d(1), e(3).
// The note on B5 describes row 5's record ("c"), which lands on row 3; the link on B4
// describes row 4's record ("b"), which lands on row 2. Both must travel with their record.
let mut s = setup(["d", "a", "e", "b", "c"]);
s.execute("data.sort", json!({"range": "A1:B5", "keys": [{"column": "A"}]})).unwrap();
assert_eq!(v(&s, "A3"), Value::text("c"));
assert_eq!(v(&s, "A5"), Value::text("e"));
let (notes, links) = notes_and_links(&s);
assert_eq!(notes[1], Some("late".to_string()), "the note moved with its record: {notes:?}");
assert_eq!(links[0], Some("https://example.com/4".to_string()), "the link moved with its record: {links:?}");
// Remove duplicates keeps the first of each value: rows 3 and 5 go, and row 4's record moves
// to row 3. The note on B5 described row 5 and goes with it.
let mut s = setup(["a", "b", "a", "c", "a"]);
s.execute("data.removeDuplicates", json!({"range": "A1:B5"})).unwrap();
assert_eq!(v(&s, "A3"), Value::text("c"));
let (notes, _) = notes_and_links(&s);
assert!(notes.iter().all(Option::is_none), "a note on a removed row goes with it: {notes:?}");
}
/// Insert › Link sorts a target as Excel's dialog does: a sheet reference, a defined name or `#…` is
/// a place in this document, anything else an existing file or web page. A file saved as a
/// `location` opens nothing in Excel (#800).
+173 -24
View File
@@ -1,7 +1,9 @@
//! A tiny, self-contained PDF 1.7 writer for GridCraft's print and PDF export.
//!
//! - The 14 standard fonts (no embedding) with WinAnsiEncoding, plus approximate width tables
//! ([`text_width`]) so callers can measure, align and wrap text.
//! ([`text_width`]) so callers can measure, align and wrap text. Characters outside
//! WinAnsiEncoding (CJK and other scripts) are set in a predefined, non-embedded Adobe-GB1 CID
//! font with the `UniGB-UTF16-H` CMap instead of being replaced by `?`.
//! - Vector drawing: rectangles, lines (solid or dashed), polygons, circles (Bézier), clipping.
//! - RGBA images as RGB image XObjects with a soft mask for alpha (run-length encoded).
//! - Coordinates are points with a **top-left origin** (y grows down); text `y` is the baseline.
@@ -125,6 +127,9 @@ impl Font {
Font::TimesBold | Font::TimesBoldItalic => Some(&metrics::TIMES_BOLD),
_ => None,
};
if needs_cid(c) {
return CID_WIDTH;
}
let Some(table) = table else { return 600 };
let ascii = |c: char| -> Option<u16> {
let i = (c as u32).checked_sub(32)? as usize;
@@ -189,6 +194,31 @@ pub fn win_ansi(c: char) -> u8 {
}
}
/// Index of the CID font in a page's font flags (after the [`Font::ALL`] faces).
const CID_FONT: usize = Font::ALL.len();
/// Number of font resources a page can use.
const FONT_SLOTS: usize = CID_FONT + 1;
/// Base font of the CID font used for characters WinAnsiEncoding lacks.
const CID_BASE_FONT: &str = "STSong-Light";
/// Default glyph width of the CID font (full-width), in 1/1000 em.
const CID_WIDTH: u16 = 1000;
/// True when `c` has no WinAnsiEncoding byte and is drawn with the CID font.
fn needs_cid(c: char) -> bool {
!c.is_control() && c != '?' && win_ansi(c) == b'?'
}
/// A PDF hex string `< … >` of `text` in UTF-16BE (the `UniGB-UTF16-H` encoding).
fn pdf_utf16_string(text: &str) -> String {
let mut s = String::with_capacity(text.len() * 4 + 2);
s.push('<');
for unit in text.encode_utf16() {
let _ = write!(s, "{unit:04X}");
}
s.push('>');
s
}
/// A PDF literal string `( … )` for `text` in WinAnsiEncoding.
fn pdf_string(text: &str) -> String {
let mut s = String::with_capacity(text.len() + 2);
@@ -210,6 +240,19 @@ fn pdf_string(text: &str) -> String {
s
}
/// URI actions use byte strings. Percent-encode non-ASCII UTF-8 before PDF string escaping.
fn pdf_uri_string(uri: &str) -> String {
let mut ascii = String::with_capacity(uri.len());
for byte in uri.bytes() {
if byte.is_ascii() {
ascii.push(byte as char);
} else {
let _ = write!(ascii, "%{byte:02X}");
}
}
pdf_string(&ascii)
}
/// A number for a content stream: finite, short.
fn n(v: f32) -> String {
let v = if v.is_finite() { v.clamp(-1.0e7, 1.0e7) } else { 0.0 };
@@ -260,13 +303,25 @@ struct ImageData {
alpha: Option<Vec<u8>>,
}
enum LinkTarget {
Uri(String),
Page { index: usize, x: f32, y: f32 },
}
struct LinkAnnotation {
/// PDF coordinates, with the origin at the bottom left.
rect: [f32; 4],
target: LinkTarget,
}
/// A page under construction. Obtain one with [`PdfDoc::add_page`].
pub struct Page {
width: f32,
height: f32,
content: String,
fonts: [bool; 12],
fonts: [bool; FONT_SLOTS],
images: Vec<ImageData>,
links: Vec<LinkAnnotation>,
depth: u32,
}
@@ -275,6 +330,8 @@ pub struct Page {
const MAX_PAGE_CONTENT: usize = 64 * 1024 * 1024;
/// Largest image (pixels) embedded.
const MAX_IMAGE_PIXELS: u64 = 40_000_000;
const MAX_PAGE_LINKS: usize = 4096;
const MAX_LINK_URI_BYTES: usize = 4096;
impl Page {
pub fn width(&self) -> f32 {
@@ -296,6 +353,42 @@ impl Page {
}
}
fn link_rect(&self, x: f32, y: f32, w: f32, h: f32) -> Option<[f32; 4]> {
if !finite(&[x, y, w, h]) || !extent(w) || !extent(h) || self.links.len() >= MAX_PAGE_LINKS {
return None;
}
let (right, bottom) = (x + w, y + h);
if !finite(&[right, bottom]) {
return None;
}
let (left, right) = (x.max(0.0), right.min(self.width));
let (top, bottom) = (y.max(0.0), bottom.min(self.height));
(right > left && bottom > top).then(|| [left, self.y(bottom), right, self.y(top)])
}
/// Adds an invisible clickable area for an external URI. Invalid or excessive links are ignored.
pub fn link_uri(&mut self, x: f32, y: f32, w: f32, h: f32, uri: &str) {
let allowed = uri
.split_once(':')
.is_some_and(|(scheme, rest)| matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https" | "mailto") && !rest.is_empty());
if !allowed || uri.len() > MAX_LINK_URI_BYTES || uri.chars().any(char::is_control) {
return;
}
if let Some(rect) = self.link_rect(x, y, w, h) {
self.links.push(LinkAnnotation { rect, target: LinkTarget::Uri(uri.to_string()) });
}
}
/// Adds a link to a location on another page of this document (zero-based page index).
pub fn link_page(&mut self, x: f32, y: f32, w: f32, h: f32, page: usize, target_x: f32, target_y: f32) {
if !finite(&[target_x, target_y]) {
return;
}
if let Some(rect) = self.link_rect(x, y, w, h) {
self.links.push(LinkAnnotation { rect, target: LinkTarget::Page { index: page, x: target_x, y: target_y } });
}
}
/// Saves the graphics state (pair with [`Page::restore`]).
pub fn save(&mut self) {
self.depth = self.depth.saturating_add(1);
@@ -460,24 +553,25 @@ impl Page {
return;
}
let fi = font.index();
if let Some(f) = self.fonts.get_mut(fi) {
*f = true;
}
let size = n(size.min(10_000.0));
let (sn, cs) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) };
// Clockwise in y-down = counter-clockwise negated in PDF's y-up space.
let s = format!(
"BT\n{}\n/F{} {} Tf\n{} {} {} {} {} {} Tm\n{} Tj\nET",
rgb_op(color, false),
fi + 1,
n(size.min(10_000.0)),
n(cs),
n(-sn),
n(sn),
n(cs),
n(x),
n(self.y(y)),
pdf_string(text)
);
let mut s = format!("BT\n{}\n{} {} {} {} {} {} Tm\n", rgb_op(color, false), n(cs), n(-sn), n(sn), n(cs), n(x), n(self.y(y)));
// Runs of WinAnsi text use `font`; runs it cannot encode use the CID font. Each `Tj`
// advances the text position, so the runs follow each other on the baseline.
let mut rest = text;
while let Some(first) = rest.chars().next() {
let cid = needs_cid(first);
let end = rest.char_indices().find(|(_, c)| needs_cid(*c) != cid).map_or(rest.len(), |(i, _)| i);
let (run, tail) = rest.split_at(end);
let (slot, string) = if cid { (CID_FONT, pdf_utf16_string(run)) } else { (fi, pdf_string(run)) };
if let Some(f) = self.fonts.get_mut(slot) {
*f = true;
}
let _ = writeln!(s, "/F{} {} Tf\n{} Tj", slot + 1, size, string);
rest = tail;
}
s.push_str("ET");
self.op(&s);
}
@@ -583,7 +677,7 @@ impl PdfDoc {
pub fn add_page(&mut self, width_pt: f32, height_pt: f32) -> &mut Page {
let width = if width_pt.is_finite() { width_pt.clamp(3.0, 14_400.0) } else { 612.0 };
let height = if height_pt.is_finite() { height_pt.clamp(3.0, 14_400.0) } else { 792.0 };
self.pages.push(Page { width, height, content: String::new(), fonts: [false; 12], images: Vec::new(), depth: 0 });
self.pages.push(Page { width, height, content: String::new(), fonts: [false; FONT_SLOTS], images: Vec::new(), links: Vec::new(), depth: 0 });
let i = self.pages.len() - 1;
&mut self.pages[i]
}
@@ -603,26 +697,54 @@ impl PdfDoc {
self.add_page(612.0, 792.0);
}
// Object numbering: 1 catalog, 2 page tree, 3 info, then fonts, then per page.
let mut used = [false; 12];
let mut used = [false; FONT_SLOTS];
for p in &self.pages {
for (u, f) in used.iter_mut().zip(p.fonts.iter()) {
*u |= *f;
}
}
let mut next = 4u32;
let mut font_ids = [0u32; 12];
let mut font_ids = [0u32; FONT_SLOTS];
for (i, u) in used.iter().enumerate() {
if *u {
font_ids[i] = next;
next += 1;
// The CID font also needs its descendant font and font descriptor.
next += if i == CID_FONT { 3 } else { 1 };
}
}
let mut objects: Vec<(u32, Vec<u8>)> = Vec::new();
let mut kids = Vec::new();
let mut annotations = Vec::new();
let page_count = self.pages.len();
let mut font_dict = String::new();
for (i, id) in font_ids.iter().enumerate() {
if *id != 0 {
let _ = write!(font_dict, "/F{} {} 0 R ", i + 1, id);
if i == CID_FONT {
let (cid_id, desc_id) = (id + 1, id + 2);
objects.push((
*id,
format!(
"<< /Type /Font /Subtype /Type0 /BaseFont /{CID_BASE_FONT} /Encoding /UniGB-UTF16-H /DescendantFonts [{cid_id} 0 R] >>"
)
.into_bytes(),
));
objects.push((
cid_id,
format!(
"<< /Type /Font /Subtype /CIDFontType0 /BaseFont /{CID_BASE_FONT} /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> /FontDescriptor {desc_id} 0 R /DW {CID_WIDTH} >>"
)
.into_bytes(),
));
objects.push((
desc_id,
format!(
"<< /Type /FontDescriptor /FontName /{CID_BASE_FONT} /Flags 6 /FontBBox [-25 -254 1000 880] /ItalicAngle 0 /Ascent 880 /Descent -120 /CapHeight 880 /StemV 93 >>"
)
.into_bytes(),
));
continue;
}
let name = Font::ALL.get(i).copied().unwrap_or_default().base_name();
let enc = if name.starts_with("Symbol") { "" } else { " /Encoding /WinAnsiEncoding" };
objects.push((*id, format!("<< /Type /Font /Subtype /Type1 /BaseFont /{name}{enc} >>").into_bytes()));
@@ -678,20 +800,47 @@ impl PdfDoc {
o.extend_from_slice(b"endstream");
objects.push((content_id, o));
let xo = if xobjects.is_empty() { String::new() } else { format!(" /XObject << {xobjects}>>") };
let mut link_refs = String::new();
for link in std::mem::take(&mut page.links) {
if matches!(&link.target, LinkTarget::Page { index, .. } if *index >= page_count) {
continue;
}
let id = next;
next += 1;
let _ = write!(link_refs, "{id} 0 R ");
annotations.push((id, link));
}
let annots = if link_refs.is_empty() { String::new() } else { format!(" /Annots [{link_refs}]") };
objects.push((
page_id,
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Resources << /ProcSet [/PDF /Text /ImageB /ImageC] /Font << {}>>{} >> /Contents {} 0 R >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Resources << /ProcSet [/PDF /Text /ImageB /ImageC] /Font << {}>>{} >> /Contents {} 0 R{} >>",
n(page.width),
n(page.height),
font_dict,
xo,
content_id
content_id,
annots
)
.into_bytes(),
));
kids.push(page_id);
}
for (id, link) in annotations {
let [x0, y0, x1, y1] = link.rect;
let action = match link.target {
LinkTarget::Uri(uri) => format!("/A << /S /URI /URI {} >>", pdf_uri_string(&uri)),
LinkTarget::Page { index, x, y } => {
let Some(&page_id) = kids.get(index) else { continue };
let height = self.pages.get(index).map_or(0.0, |p| p.height);
format!("/Dest [{page_id} 0 R /XYZ {} {} null]", n(x), n(height - y))
}
};
objects.push((
id,
format!("<< /Type /Annot /Subtype /Link /Rect [{} {} {} {}] /Border [0 0 0] {action} >>", n(x0), n(y0), n(x1), n(y1)).into_bytes(),
));
}
objects.push((1, b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()));
let kids_s: Vec<String> = kids.iter().map(|k| format!("{k} 0 R")).collect();
objects.push((2, format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids_s.join(" "), kids.len()).into_bytes()));
+41
View File
@@ -76,6 +76,29 @@ fn drawing_and_text() {
assert!(find(&pdf, b"0 792 m 612 0 l S").is_some());
}
#[test]
fn uri_and_page_links_are_real_pdf_annotations() {
let mut d = PdfDoc::new();
let first = d.add_page(200.0, 300.0);
first.link_uri(10.0, 20.0, 80.0, 30.0, "https://example.org/a(b)?q=£");
first.link_page(100.0, 40.0, 50.0, 20.0, 1, 35.0, 60.0);
first.link_uri(-20.0, -20.0, 40.0, 40.0, "mailto:a@example.org");
first.link_uri(0.0, 0.0, f32::NAN, 10.0, "https://bad.example");
first.link_uri(0.0, 0.0, 10.0, 10.0, "javascript:alert(1)");
let second = d.add_page(100.0, 200.0);
second.link_page(0.0, 0.0, 10.0, 10.0, 99, 0.0, 0.0);
let pdf = d.finish();
check_xref(&pdf);
let s = String::from_utf8_lossy(&pdf);
assert_eq!(s.matches("/Subtype /Link").count(), 3);
assert!(s.contains("/Annots ["));
assert!(s.contains("/Rect [10 250 90 280]"));
assert!(s.contains("/Rect [0 280 20 300]"));
assert!(s.contains("/URI (https://example.org/a\\(b\\)?q=%C2%A3)"));
assert!(s.contains("/XYZ 35 140 null"));
assert!(!s.contains("https://bad.example"));
}
#[test]
fn images_with_alpha() {
let mut d = PdfDoc::new();
@@ -277,3 +300,21 @@ fn info_strings_are_capped() {
let pdf = doc.finish();
assert_eq!(utf16_entry(&pdf, "/Title").chars().count(), MAX_INFO_CHARS);
}
#[test]
fn cjk_text_is_not_replaced_by_question_marks() {
let mut doc = PdfDoc::new();
let p = doc.add_page(612.0, 792.0);
p.text(10.0, 20.0, 10.0, Font::Helvetica, [0, 0, 0], "签字清单");
p.text(10.0, 40.0, 10.0, Font::Helvetica, [0, 0, 0], "No. 编号 2");
let pdf = doc.finish();
check_xref(&pdf);
assert!(find(&pdf, b"(????) Tj").is_none(), "CJK text written as question marks");
assert!(find(&pdf, b"<7B7E5B576E055355> Tj").is_some());
assert!(find(&pdf, b"(No. ) Tj").is_some());
assert!(find(&pdf, b"<7F1653F7> Tj").is_some());
assert!(find(&pdf, b"( 2) Tj").is_some());
assert!(find(&pdf, b"/Encoding /UniGB-UTF16-H").is_some());
// Measured as full-width glyphs, matching the CID font's default width.
assert_eq!(text_width(Font::Helvetica, 10.0, "签字"), 20.0);
}
+4 -1
View File
@@ -13,7 +13,10 @@ use std::fmt::Write as _;
use serde_json::Value;
fn main() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contributors");
// Read when the script runs: `env!` would keep the path from when the script was compiled,
// which goes stale if the checkout moves (and a missing file reruns the script every build).
let manifest = std::env::var_os("CARGO_MANIFEST_DIR").map(std::path::PathBuf::from).unwrap_or_default();
let dir = manifest.join("../../contributors");
let json = read(&dir.join("contributors.json"), "the About window shows no contributors", |t| {
serde_json::from_str::<Value>(t).map_err(|e| e.to_string())
})
+1 -1
View File
@@ -263,7 +263,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
"ui.dialog.confirm" => match app.dialog.take() {
Some(d) => match d.command {
Some(cmd) => {
let params = Json::Object(d.values.clone());
let params = crate::dialogs::build_params(&d);
let r = app.run(cmd, params);
if r.is_err() {
app.dialog = Some(d);
+7 -2
View File
@@ -67,11 +67,16 @@ impl DesktopTheme {
}
}
/// The session-bus connection the watcher keeps between reads, opened on first use.
/// The session-bus connection the watcher keeps between reads, opened on first use. Off Linux
/// there is no bus to keep, and a unit struct rather than `()` keeps `let mut link = …` from
/// reading as a unit binding, which `cargo xtask ci` refuses on those platforms.
#[cfg(target_os = "linux")]
type Link = Option<zbus::blocking::Connection>;
#[cfg(not(target_os = "linux"))]
type Link = ();
#[derive(Default)]
pub(crate) struct NoLink;
#[cfg(not(target_os = "linux"))]
type Link = NoLink;
/// Ask the portal once, over `link`'s connection (opened if absent, dropped if the bus went
/// away). D-Bus calls block, so this belongs off the UI thread: the watcher's thread is its
+310 -36
View File
@@ -51,13 +51,28 @@ impl Dialog {
&self.name
}
/// Sets a form value. In Zoom, a Magnification choice is also the percentage `view.zoom` stores.
/// Sets a form value. In Zoom, a Magnification choice is also the percentage `view.zoom`
/// stores, a typed percentage selects Custom, and Fit selection makes OK run
/// `view.zoomToSelection` (its view size was captured when the dialog opened).
pub(crate) fn set_value(&mut self, key: &str, value: Json) {
if self.name == "zoom"
&& key == "preset"
&& let Some(p) = value.as_str().and_then(|s| s.parse::<f64>().ok())
{
self.values.insert("percent".into(), json!(p));
if self.name == "zoom" {
match key {
"preset" => match value.as_str() {
Some("fit") => self.command = Some("view.zoomToSelection"),
Some(s) => {
self.command = Some("view.zoom");
if let Ok(p) = s.parse::<f64>() {
self.values.insert("percent".into(), json!(p));
}
}
None => {}
},
"percent" => {
self.command = Some("view.zoom");
self.values.insert("preset".into(), json!(zoom_preset(value.as_f64().unwrap_or(0.0))));
}
_ => {}
}
}
self.values.insert(key.into(), value);
}
@@ -405,7 +420,11 @@ impl Dialog {
Text { key: "header", label: "Header (&L left, &C center, &R right; &P page, &N pages, &D date, &A sheet):" },
Text { key: "footer", label: "Footer:" },
],
json!({"header": "", "footer": "&CPage &P of &N"}),
{
let sheet = app.session.active().and_then(|d| d.wb.active());
let (header, footer) = sheet.map(|s| (s.print.header.clone(), s.print.footer.clone())).unwrap_or_default();
json!({"header": header, "footer": footer})
},
),
"pageSetup" => {
// Opens on the active sheet's saved margins, so OK without edits rewrites them unchanged.
@@ -446,9 +465,33 @@ impl Dialog {
"removeDuplicates",
"Remove Duplicates",
"data.removeDuplicates",
vec![Check { key: "header", label: "My data has headers" }, Text { key: "columnsText", label: "Columns (blank = all, e.g. A,C):" }],
json!({"header": true}),
vec![
Text { key: "range", label: "Range:" },
Check { key: "header", label: "My data has headers" },
Text { key: "columnsText", label: "Columns (blank = all, e.g. A,C):" },
],
json!({"range": remove_duplicates_range(app, &sel), "header": true}),
),
"advancedFilter" => {
let list = app
.session
.active()
.and_then(|d| d.wb.active().map(|sh| gridcraft_engine::selection::current_region(sh, d.selection.active).a1()))
.unwrap_or(sel.clone());
let mut d = Dialog::form(
"advancedFilter",
"Advanced Filter",
"data.advancedFilter",
vec![],
json!({"action": "inPlace", "range": list, "criteria": "", "copyTo": "", "unique": false}),
);
for key in ["action", "range", "criteria", "copyTo", "unique"] {
if let Some(value) = params.get(key) {
d.set_value(key, value.clone());
}
}
d
}
"goalSeek" => Dialog::custom("goalSeek", "Goal Seek", json!({"set": active, "to": "0", "changing": ""})),
"forecastSheet" => Dialog::form(
"forecastSheet",
@@ -534,20 +577,19 @@ impl Dialog {
],
json!({"groupBy": "A", "function": "sum"}),
),
"zoom" => Dialog::form(
"zoom",
"Zoom",
"view.zoom",
vec![
Choice {
key: "preset",
label: "Magnification",
options: vec![("200", "200%"), ("100", "100%"), ("75", "75%"), ("50", "50%"), ("25", "25%")],
},
Number { key: "percent", label: "Custom (%):" },
],
json!({"percent": app.session.active().and_then(|d| d.wb.active().map(|s| s.zoom)).unwrap_or(100)}),
),
"zoom" => {
// The current zoom is pre-selected (Custom when it matches no preset). Fit
// selection needs the grid's size, so it is captured here for `view.zoomToSelection`.
let zoom = app.session.active().and_then(|d| d.wb.active().map(|s| s.zoom)).unwrap_or(100);
let view = app.grid.cells_rect.unwrap_or(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), vec2(1200.0, 700.0)));
Dialog::form(
"zoom",
"Zoom",
"view.zoom",
vec![],
json!({"percent": zoom, "preset": zoom_preset(zoom as f64), "viewWidth": view.width(), "viewHeight": view.height()}),
)
}
"defineName" => {
// Name Manager › Edit… passes the name being edited (`oldName`, `oldScope`) and
// its current fields; New… and Define Name start from the selection.
@@ -680,7 +722,11 @@ impl Dialog {
}],
json!({"kind": "blanks"}),
),
"sort" => Dialog::custom("sort", "Sort", json!({"header": true, "levels": [{"column": "A", "order": "asc"}]})),
"sort" => {
// Seed "Sort by" from the column the user is actually in (#1906).
let col = app.session.active().map(|d| gridcraft_engine::core::col_to_letters(d.selection.active.col)).unwrap_or_else(|| "A".into());
Dialog::custom("sort", "Sort", json!({"header": true, "levels": [{"column": col, "order": "asc"}]}))
}
"nameManager" => Dialog::custom("nameManager", "Name Manager", json!({})),
"manageRules" => Dialog::custom("manageRules", "Conditional Formatting Rules Manager", json!({})),
"commandSearch" => Dialog::custom("commandSearch", "Search Commands", json!({})),
@@ -841,6 +887,22 @@ fn validation_defaults(app: &SheetApp) -> Json {
v
}
/// The range Remove Duplicates starts with. A block selection is used as given; with only the
/// cursor placed, the table's data rows or the current region, as the command picks when it gets
/// no range (a single-cell range would remove nothing).
fn remove_duplicates_range(app: &SheetApp, sel: &str) -> String {
let Some(d) = app.session.active() else { return sel.to_string() };
let Some(sh) = d.wb.active() else { return sel.to_string() };
if !d.selection.current().is_single() {
return sel.to_string();
}
let range = match sh.table_at(d.selection.active) {
Some(t) => t.data_range().map(|dr| gridcraft_engine::core::RangeRef::new(t.range.start, dr.end)).unwrap_or(t.range),
None => gridcraft_engine::selection::current_region(sh, d.selection.active),
};
range.a1()
}
fn abs(r: &gridcraft_engine::core::RangeRef) -> String {
let a = |c: gridcraft_engine::core::CellRef| format!("${}${}", gridcraft_engine::core::col_to_letters(c.col), c.row + 1);
if r.is_single() { a(r.start) } else { format!("{}:{}", a(r.start), a(r.end)) }
@@ -950,10 +1012,44 @@ fn data_validation_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, o
ok_cancel(ui, confirm, open);
}
fn advanced_filter_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
use Field::*;
let copy = d.values.get("action").and_then(Json::as_str) == Some("copy");
d.fields = vec![
Choice {
key: "action",
label: "Action",
options: vec![("inPlace", "Show matching rows in place"), ("copy", "Copy matching rows elsewhere")],
},
Text { key: "range", label: "List range:" },
Text { key: "criteria", label: "Criteria range (optional):" },
];
if copy {
d.fields.push(Text { key: "copyTo", label: "Copy to:" });
}
d.fields.push(Check { key: "unique", label: "Unique rows only" });
form_fields(ui, d);
// Constrain the button row: the shared right-to-left layout otherwise
// consumes the window's remaining height instead of fitting the buttons.
ui.allocate_ui(vec2(ui.available_width(), ui.spacing().interact_size.y + 8.0), |ui| ok_cancel(ui, confirm, open));
}
/// Builds the command params from form values for dialogs that need translation.
fn build_params(d: &Dialog) -> Json {
/// The parameters OK sends to the dialog's command: its field values, reshaped per dialog.
pub(crate) fn build_params(d: &Dialog) -> Json {
let mut v = Json::Object(d.values.clone());
match d.name.as_str() {
"advancedFilter" => {
if let Some(m) = v.as_object_mut() {
m.remove("action");
if d.values.get("action").and_then(Json::as_str) != Some("copy") {
m.remove("copyTo");
}
if m.get("criteria").and_then(Json::as_str).is_some_and(|s| s.trim().is_empty()) {
m.remove("criteria");
}
}
}
"dataValidation" => {
let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any");
let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between");
@@ -995,13 +1091,6 @@ fn build_params(d: &Dialog) -> Json {
}
}
}
"zoom" => {
if let Some(p) = d.values.get("preset").and_then(Json::as_str)
&& d.values.get("percent").is_none()
{
v["percent"] = json!(p.parse::<f64>().unwrap_or(100.0));
}
}
"cfQuick" => {
let mut rule = Map::new();
for (k, val) in &d.values {
@@ -1045,6 +1134,8 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
"recommendedCharts" => 600.0,
"analysisToolPak" => 420.0,
"solver" => 460.0,
"zoom" => 220.0,
"pasteSpecial" => 480.0,
_ => 380.0,
};
let window = egui::Window::new(app.ui.language.tr(&d.title))
@@ -1052,17 +1143,25 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
.collapsible(false)
.resizable(false)
.default_width(width)
.anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -40.0))
// A default position, not an anchor: egui anchors can't be dragged, and dialogs move by their title bar.
.pivot(egui::Align2::CENTER_CENTER)
.default_pos(ctx.content_rect().center() + vec2(0.0, -40.0))
.open(&mut win_open);
let window = if d.name == "insertPicture" { window.default_height(240.0) } else { window };
let window = match d.name.as_str() {
"insertPicture" => window.default_height(240.0),
"advancedFilter" => window.default_height(0.0),
_ => window,
};
window.show(ctx, |ui| {
ui.set_min_width(width - 20.0);
match d.name.as_str() {
"formatCells" => format_cells(app, ui, &mut d, &mut confirm),
"dataValidation" => data_validation_form(ui, &mut d, &mut confirm, &mut open),
"advancedFilter" => advanced_filter_form(ui, &mut d, &mut confirm, &mut open),
"insertFunction" => insert_function(app, ui, &mut d, &mut confirm),
"find" => find(app, ui, &mut d),
"goTo" => go_to(app, ui, &mut d, &mut confirm),
"zoom" => zoom(ui, &mut d, &mut confirm, &mut open),
"sort" => sort(app, ui, &mut d, &mut confirm),
"nameManager" => name_manager(app, ui, &mut d, &mut open),
"manageRules" => manage_rules(app, ui),
@@ -1070,6 +1169,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
"selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open),
"commandSearch" => command_search(app, ui, &mut d, &mut confirm),
"goalSeek" => goal_seek(app, ui, &mut d, &mut confirm),
"pasteSpecial" => paste_special(app, ui, &mut d, &mut confirm, &mut open),
"analysisToolPak" => analysis_toolpak(app, ui, &mut d, &mut confirm, &mut open),
"solver" => solver(ui, &mut d, &mut confirm, &mut open),
"solverResults" => solver_results(app, ui, &mut d, &mut open),
@@ -1320,6 +1420,79 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
}
}
/// Paste Special as Excel lays it out: Paste and Operation each in two columns of options, Skip
/// blanks and Transpose side by side, and Paste Link as a button that pastes at once. The options
/// come from the dialog's `fields`, so the letter shortcuts and the control channel see the same.
fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
let lang = app.ui.language;
let t = theme::Tokens::get(ui.ctx());
let choices: Vec<(&'static str, &'static str, Vec<(&'static str, &'static str)>)> = d
.fields
.iter()
.filter_map(|f| match f {
Field::Choice { key, label, options } => Some((*key, *label, options.clone())),
_ => None,
})
.collect();
for (key, label, options) in choices {
ui.add_space(4.0);
ui.label(egui::RichText::new(lang.tr(label)).strong());
egui::Frame::new().stroke(Stroke::new(1.0, t.separator)).corner_radius(4.0).inner_margin(egui::Margin::symmetric(10, 6)).show(ui, |ui| {
ui.set_min_width(ui.available_width());
let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string();
// Column by column, as Excel fills them.
let rows = options.len().div_ceil(2);
egui::Grid::new(("paste_special", key)).num_columns(2).min_col_width(200.0).spacing(vec2(16.0, 4.0)).show(ui, |ui| {
for r in 0..rows {
for c in 0..2 {
if let Some((value, text)) = options.get(c * rows + r) {
if ui.radio(cur == *value, lang.tr(text)).clicked() {
d.set_value(key, json!(value));
}
} else {
ui.label("");
}
}
ui.end_row();
}
});
});
}
ui.add_space(8.0);
// In the same columns as the options above.
egui::Frame::new().inner_margin(egui::Margin::symmetric(11, 0)).show(ui, |ui| {
egui::Grid::new("paste_special_checks").num_columns(2).min_col_width(200.0).spacing(vec2(16.0, 4.0)).show(ui, |ui| {
for (key, label) in [("skipBlanks", "Skip blanks"), ("transpose", "Transpose (E)")] {
let mut b = d.values.get(key).and_then(Json::as_bool).unwrap_or(false);
if ui.checkbox(&mut b, lang.tr(label)).changed() {
d.values.insert(key.into(), json!(b));
}
}
ui.end_row();
});
});
ui.add_space(8.0);
ui.separator();
ui.horizontal(|ui| {
// Excel's Paste Link pastes references to the copied cells straight away.
if ui.button(lang.tr("Paste Link")).on_hover_text(lang.tr("Paste formulas that refer to the copied cells")).clicked() {
d.values.insert("what".into(), json!("all"));
d.values.insert("operation".into(), json!("none"));
d.values.insert("link".into(), json!(true));
*confirm = true;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE)).fill(t.accent));
if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) {
*confirm = true;
}
if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() {
*open = false;
}
});
});
}
/// Paste Special's legacy mnemonics select options; Enter still performs the paste.
fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) {
if ctx.text_edit_focused() {
@@ -1962,6 +2135,43 @@ fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
}
}
/// Zoom's Magnification presets, as `preset` values and labels, in Excel's order.
const ZOOM_PRESETS: [(&str, &str); 5] = [("200", "200%"), ("100", "100%"), ("75", "75%"), ("50", "50%"), ("25", "25%")];
/// The `preset` that shows a zoom percentage: its Magnification entry, or Custom.
fn zoom_preset(percent: f64) -> &'static str {
ZOOM_PRESETS.iter().map(|(val, _)| *val).find(|val| val.parse::<f64>().ok() == Some(percent)).unwrap_or("custom")
}
/// View › Zoom, laid out as Excel's: one Magnification column of 200% to 25%, Fit selection and
/// Custom with its percentage box. The current zoom is pre-selected; typing a percentage
/// selects Custom; Fit selection zooms so the selected cells fill the grid.
fn zoom(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
let lang = crate::i18n::current(ui.ctx());
let cur = d.values.get("preset").and_then(Json::as_str).unwrap_or("custom").to_string();
ui.label(egui::RichText::new(lang.tr("Magnification")).strong());
for (val, text) in ZOOM_PRESETS {
if ui.radio(cur == val, text).clicked() {
d.set_value("preset", json!(val));
}
}
if ui.radio(cur == "fit", lang.tr("Fit selection")).clicked() {
d.set_value("preset", json!("fit"));
}
ui.horizontal(|ui| {
if ui.radio(cur == "custom", format!("{}:", lang.tr("Custom"))).clicked() {
d.set_value("preset", json!("custom"));
}
// Whole percentages, as Excel shows them; the engine clamps to 10..400 as well.
let mut percent = d.values.get("percent").and_then(Json::as_f64).unwrap_or(100.0).round() as i64;
if ui.add(egui::DragValue::new(&mut percent).range(10..=400).speed(1.0)).changed() {
d.set_value("percent", json!(percent));
}
ui.label("%");
});
ok_cancel(ui, confirm, open);
}
fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
let names: Vec<String> = app.session.active().map(|doc| doc.wb.names.iter().map(|n| n.name.clone()).collect()).unwrap_or_default();
for n in names {
@@ -1995,12 +2205,43 @@ fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut boo
ui.checkbox(&mut header, "My list has headers");
d.values.insert("header".into(), json!(header));
let mut levels: Vec<Json> = d.values.get("levels").and_then(Json::as_array).cloned().unwrap_or_default();
// Columns of the region around the active cell, labelled with their header (or first value).
let columns: Vec<(String, String)> = app
.session
.active()
.and_then(|d| {
let sh = d.wb.active()?;
let region = gridcraft_engine::selection::current_region(sh, d.selection.active);
Some(
(region.start.col..=region.end.col)
.map(|c| {
let letter = gridcraft_engine::core::col_to_letters(c);
// The region starts at the header row when one is present, so the
// label always comes from start.row: header text with headers on,
// first data value with them off.
let label_row = region.start.row;
let text = gridcraft_engine::display::cell_text(&d.wb, sh, gridcraft_engine::core::CellRef::new(label_row, c));
let text: String = text.chars().take(24).collect();
(letter.clone(), if text.is_empty() { letter } else { format!("{letter}: {text}") })
})
.collect(),
)
})
.unwrap_or_default();
let mut remove = None;
for (i, lv) in levels.iter_mut().enumerate() {
ui.horizontal(|ui| {
ui.label(if i == 0 { "Sort by" } else { "Then by" });
let mut col = lv["column"].as_str().unwrap_or("A").to_string();
ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0));
if columns.is_empty() {
ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0));
} else {
egui::ComboBox::from_id_salt(("sort_col", i)).selected_text(col.clone()).show_ui(ui, |ui| {
for (letter, label) in &columns {
ui.selectable_value(&mut col, letter.clone(), label.as_str());
}
});
}
lv["column"] = json!(col.to_ascii_uppercase());
let mut by = lv["by"].as_str().unwrap_or("values").to_string();
egui::ComboBox::from_id_salt(("by", i))
@@ -2030,7 +2271,15 @@ fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut boo
levels.remove(i);
}
if ui.button("+ Add Level").clicked() {
levels.push(json!({"column": "B", "order": "asc"}));
let used: Vec<String> = levels.iter().filter_map(|l| l.get("column").and_then(Json::as_str).map(str::to_string)).collect();
let next = columns
.iter()
.map(|(l, _)| l)
.find(|l| !used.contains(l))
.cloned()
.or_else(|| columns.last().map(|(l, _)| l.clone()))
.unwrap_or_else(|| "B".into());
levels.push(json!({"column": next, "order": "asc"}));
}
d.values.insert("levels".into(), Json::Array(levels.clone()));
let mut open = true;
@@ -2831,6 +3080,31 @@ mod data_validation_tests {
mod tests {
use super::*;
#[test]
fn remove_duplicates_prefills_the_selection_and_submits_the_edited_range() {
let mut session = gridcraft_engine::Session::default();
session.new_workbook();
session.execute("selection.set", json!({"range": "B2:C5"})).unwrap();
let mut app = SheetApp::new(session, Default::default());
let mut dialog = Dialog::open(&mut app, "removeDuplicates", json!({})).unwrap();
assert_eq!(dialog.values.get("range"), Some(&json!("B2:C5")));
assert!(dialog.fields.iter().any(|field| matches!(field, Field::Text { key: "range", .. })));
assert_eq!(build_params(&dialog)["range"], "B2:C5");
dialog.set_value("range", json!("D2:E6"));
assert_eq!(build_params(&dialog)["range"], "D2:E6");
}
#[test]
fn remove_duplicates_with_only_the_cursor_prefills_the_current_region() {
let mut session = gridcraft_engine::Session::default();
session.new_workbook();
session.execute("range.setValues", json!({"range": "B2:C4", "values": [["a", 1], ["a", 1], ["b", 2]]})).unwrap();
session.execute("selection.set", json!({"range": "C3"})).unwrap();
let mut app = SheetApp::new(session, Default::default());
let dialog = Dialog::open(&mut app, "removeDuplicates", json!({})).unwrap();
assert_eq!(dialog.values.get("range"), Some(&json!("B2:C4")));
}
#[test]
fn data_validation_opens_on_the_rule_of_the_active_cell() {
let mut session = gridcraft_engine::Session::default();
+2
View File
@@ -189,6 +189,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Gridlines" => "Mřížka",
"Headings" => "Záhlaví",
"Zoom" => "Lupa",
"Magnification" => "Měřítko",
"Fit selection" => "Přizpůsobit výběru",
"Forecast\nSheet" => "List\nprognózy",
"Add footer" => "Přidat zápatí",
"Add header" => "Přidat záhlaví",
+2
View File
@@ -193,6 +193,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Gridlines" => "Gitternetzlinien",
"Headings" => "Überschriften",
"Zoom" => "Zoom",
"Magnification" => "Vergrößerung",
"Fit selection" => "An Markierung anpassen",
"Forecast\nSheet" => "Prognose-\nblatt",
"Add footer" => "Fußzeile hinzufügen",
"Add header" => "Kopfzeile hinzufügen",
+2
View File
@@ -192,6 +192,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Gridlines" => "Líneas de cuadrícula",
"Headings" => "Encabezados",
"Zoom" => "Zoom",
"Magnification" => "Ampliación",
"Fit selection" => "Ajustar la selección",
"Forecast\nSheet" => "Hoja de\npronóstico",
"Add footer" => "Agregar pie de página",
"Add header" => "Agregar encabezado",
+2
View File
@@ -213,6 +213,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Gridlines" => "Quadrillage",
"Headings" => "En-têtes",
"Zoom" => "Zoom",
"Magnification" => "Zoom",
"Fit selection" => "Ajuster la sélection",
"100%" => "100 %",
"Zoom to\nSelection" => "Zoom sur la\nsélection",
"Freeze\nPanes" => "Figer les\nvolets",
+1
View File
@@ -212,6 +212,7 @@ pub(super) const CORE: &[(&str, &str)] = &[
("Gridlines", "目盛線"),
("Headings", "見出し"),
("Zoom", "ズーム"),
("Fit selection", "選択範囲に合わせる"),
("100%", "100%"),
("Zoom to\nSelection", "選択範囲を拡大"),
("Freeze\nPanes", "ウィンドウ枠の固定"),
+2
View File
@@ -210,6 +210,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Gridlines" => "눈금선",
"Headings" => "머리글",
"Zoom" => "확대/축소",
"Magnification" => "배율",
"Fit selection" => "선택 영역에 맞춤",
"100%" => "100%",
"Zoom to\nSelection" => "선택 영역에\n맞춤",
"Freeze\nPanes" => "틀\n고정",
+2
View File
@@ -207,6 +207,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Gridlines" => "Linhas de Grade",
"Headings" => "Títulos",
"Zoom" => "Zoom",
"Fit selection" => "Ajustar seleção",
"100%" => "100%",
"Zoom to\nSelection" => "Zoom na\nSeleção",
"Freeze\nPanes" => "Congelar\nPainéis",
@@ -426,6 +427,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Number Stored as Text" => "Número Armazenado como Texto",
"Convert to Number" => "Converter em Número",
"Error checking options" => "Opções de verificação de erros",
"Paste formulas that refer to the copied cells" => "Colar fórmulas que se referem às células copiadas",
"Row Height" => "Altura da Linha",
"Column Width" => "Largura da Coluna",
"Standard Width" => "Largura Padrão",
+2
View File
@@ -210,6 +210,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Gridlines" => "Сетка",
"Headings" => "Заголовки",
"Zoom" => "Масштаб",
"Magnification" => "Масштаб",
"Fit selection" => "По выделению",
"100%" => "100%",
"Zoom to\nSelection" => "Масштаб по\nвыделению",
"Freeze\nPanes" => "Закрепить\nобласти",
+2
View File
@@ -205,6 +205,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Gridlines" => "Linije mreže",
"Headings" => "Zaglavlja",
"Zoom" => "Uvećanje",
"Magnification" => "Uvećanje",
"Fit selection" => "Uklopi izbor",
"100%" => "100%",
"Zoom to\nSelection" => "Uvećaj na\nselekciju",
"Freeze\nPanes" => "Zamrzni\nokna",
+2
View File
@@ -210,6 +210,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Gridlines" => "网格线",
"Headings" => "标题",
"Zoom" => "缩放",
"Magnification" => "缩放比例",
"Fit selection" => "恰好容纳选定区域",
"100%" => "100%",
"Zoom to\nSelection" => "缩放到\n所选内容",
"Freeze\nPanes" => "冻结\n窗格",
@@ -292,7 +292,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
("APR", Entry::Finish(Action::Pending("data.recentSources"))), // pending; Excel: Recent Sources; gap #12; reserved until the command lands
("APS", Entry::Finish(Action::Command("data.queriesConnections"))), // Excel: Show Queries
("APT", Entry::Finish(Action::Pending("data.fromTable"))), // pending; Excel: From Table (query); gap #12; reserved until the command lands
("AQ", Entry::Finish(Action::Command("data.advancedFilter"))), // Excel: Advanced Filter
("AQ", Entry::Finish(Action::Dialog("advancedFilter"))), // Excel: Advanced Filter
("AR", Entry::Continue), // group: group: Queries & Connections
("ARA", Entry::Finish(Action::Command("data.refreshAll"))), // Excel: Refresh All
("ARC", Entry::Finish(Action::Pending("data.cancelRefresh"))), // pending; Excel: Cancel Refresh; gap #12; reserved until the command lands
+3 -3
View File
@@ -1972,8 +1972,8 @@
},
{
"seq": "AQ",
"kind": "command",
"target": "data.advancedFilter",
"kind": "dialog",
"target": "advancedFilter",
"excel": "Advanced Filter",
"note": ""
},
@@ -2530,4 +2530,4 @@
"note": "Alt+Q search box does not exist yet (issue #49)"
}
]
}
}
+5
View File
@@ -2002,6 +2002,11 @@ fn data(app: &mut SheetApp, ui: &mut Ui) {
if __k.clicked() {
act(app, "data.reapply", json!({}));
}
let __k = small_button(ui, Icon::Filter, "Advanced", "Advanced Filter", false);
control_keytip(app, ui, &__k, "dialog:advancedFilter");
if __k.clicked() {
app.open_dialog("advancedFilter", json!({}));
}
});
sep(ui);
let __k = big_button(ui, Icon::TextColumns, "Text to\nColumns", "Text to Columns", false);
+72
View File
@@ -0,0 +1,72 @@
//! Advanced Filter's dialog must route both actions to the same engine command.
use egui::vec2;
use egui_kittest::kittest::Queryable;
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use gridcraft_ui_egui::control::{Outcome, handle};
use serde_json::{Value as Json, json};
fn harness() -> egui_kittest::Harness<'static, SheetApp> {
let mut session = Session::new();
session.new_workbook();
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
h.run_steps(6);
h
}
fn control(h: &mut egui_kittest::Harness<'static, SheetApp>, method: &str, params: Json) -> Json {
let ctx = h.ctx.clone();
match handle(h.state_mut(), &ctx, method, &params) {
Outcome::Done(v) => v,
Outcome::Screenshot { .. } => json!(null),
}
}
#[test]
fn dialog_offers_copy_action_and_runs_unique_extract() {
let mut h = harness();
h.state_mut().run("range.setValues", json!({"range": "A1", "values": [["Name"], ["A"], ["A"], ["B"]]})).unwrap();
h.state_mut().open_dialog("advancedFilter", json!({}));
h.run_steps(6);
h.get_by_label("Copy matching rows elsewhere").click();
h.run_steps(6);
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::new("dialog").with("advancedFilter"))).unwrap();
assert!(rect.width() < 500.0 && rect.height() < 400.0, "dialog must fit its controls: {rect:?}");
assert!(
h.state()
.dialog
.as_ref()
.is_some_and(|d| d.fields.iter().any(|f| matches!(f, gridcraft_ui_egui::dialogs::Field::Text { key: "copyTo", .. })))
);
assert_eq!(h.state().dialog.as_ref().unwrap().values["range"], "A1:A4");
control(&mut h, "ui.dialog.set", json!({"field": "copyTo", "value": "C1"}));
control(&mut h, "ui.dialog.set", json!({"field": "unique", "value": true}));
h.run_steps(6);
h.get_by_label(" OK ").click();
h.run_steps(6);
assert!(h.state().dialog.is_none());
assert!(h.state().message.is_none());
assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().value(gridcraft_engine::core::CellRef::parse("C3").unwrap()).display(), "B");
}
#[test]
fn in_place_action_does_not_use_stale_copy_destination() {
let mut h = harness();
h.state_mut().run("range.setValues", json!({"range": "A1", "values": [["Name"], ["A"], ["B"]]})).unwrap();
h.state_mut().run("range.setValues", json!({"range": "H1", "values": [["Name"], ["B"]]})).unwrap();
control(&mut h, "ui.dialog", json!({"name": "advancedFilter"}));
control(&mut h, "ui.dialog.set", json!({"field": "range", "value": "A1:A3"}));
control(&mut h, "ui.dialog.set", json!({"field": "criteria", "value": "H1:H2"}));
control(&mut h, "ui.dialog.set", json!({"field": "copyTo", "value": "K1"}));
let r = control(&mut h, "ui.dialog.confirm", json!({}));
assert_eq!(r["ok"], true, "{r}");
assert!(h.state().session.active().unwrap().wb.active().unwrap().is_row_hidden(1));
assert!(h.state().session.active().unwrap().wb.active().unwrap().value(gridcraft_engine::core::CellRef::parse("K1").unwrap()).is_empty());
}
+60
View File
@@ -0,0 +1,60 @@
//! `ui.dialog.confirm` runs a dialog's command with the parameters its OK button sends (#1459).
use egui::vec2;
use gridcraft_engine::Session;
use gridcraft_engine::model::CfRule;
use gridcraft_ui_egui::SheetApp;
use gridcraft_ui_egui::control::{Outcome, handle};
use serde_json::{Value as Json, json};
fn harness() -> egui_kittest::Harness<'static, SheetApp> {
let mut session = Session::new();
session.new_workbook();
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
h.run_steps(6);
h
}
fn control(h: &mut egui_kittest::Harness<'static, SheetApp>, method: &str, params: Json) -> Json {
let ctx = h.ctx.clone();
match handle(h.state_mut(), &ctx, method, &params) {
Outcome::Done(v) => v,
Outcome::Screenshot { .. } => json!(null),
}
}
#[test]
fn confirming_a_highlight_cells_rule_adds_the_rule_like_ok() {
let mut h = harness();
h.state_mut().run("range.setValues", json!({"range": "A1", "values": [[1], [3]]})).unwrap();
h.state_mut().run("selection.set", json!({"range": "A1:A2"})).unwrap();
// Home › Conditional Formatting › Highlight Cells Rules › Greater Than…
control(&mut h, "ui.dialog", json!({"name": "cfQuick", "type": "cellIs", "operator": "greaterThan", "title": "Greater Than"}));
control(&mut h, "ui.dialog.set", json!({"field": "value", "value": "2"}));
let r = control(&mut h, "ui.dialog.confirm", json!({}));
assert_eq!(r["ok"], json!(true), "{r}");
assert!(h.state().dialog.is_none(), "the dialog closed");
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
assert_eq!(sheet.cond_formats.len(), 1, "{:?}", sheet.cond_formats);
let rule = &sheet.cond_formats[0].rule;
assert!(matches!(rule, CfRule::CellIs { a, .. } if a == "2"), "{rule:?}");
}
#[test]
fn confirming_text_to_columns_splits_on_the_ticked_delimiter() {
let mut h = harness();
h.state_mut().run("range.setValues", json!({"range": "A1", "values": [["a;b"], ["c;d"]]})).unwrap();
h.state_mut().run("selection.set", json!({"range": "A1:A2"})).unwrap();
control(&mut h, "ui.dialog", json!({"name": "textToColumns"}));
control(&mut h, "ui.dialog.set", json!({"field": "semicolon", "value": true}));
let r = control(&mut h, "ui.dialog.confirm", json!({}));
assert_eq!(r["ok"], json!(true), "{r}");
let read = h.state_mut().run("sheet.read", json!({"range": "A1:B2"})).unwrap();
assert_eq!(read["values"], json!([["a", "b"], ["c", "d"]]), "{read}");
}
+70
View File
@@ -0,0 +1,70 @@
//! Dialogs can be dragged by their title bar, like Excel's, and stay where they are dropped.
use egui::{Event, Modifiers, PointerButton, vec2};
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness() -> Harness {
let mut session = Session::new();
session.new_workbook();
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
h.run_steps(6);
h
}
fn dialog_rect(h: &Harness, name: &str) -> egui::Rect {
h.ctx.memory(|m| m.area_rect(egui::Id::new("dialog").with(name))).unwrap()
}
fn button(h: &mut Harness, pos: egui::Pos2, pressed: bool) {
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(2);
}
fn drag_title_bar(h: &mut Harness, name: &str, by: egui::Vec2) {
let start = dialog_rect(h, name).center_top() + vec2(0.0, 10.0);
h.input_mut().events.push(Event::PointerMoved(start));
h.run_steps(2);
button(h, start, true);
for step in 1..=4 {
h.input_mut().events.push(Event::PointerMoved(start + by * (step as f32 / 4.0)));
h.run_steps(1);
}
button(h, start + by, false);
}
#[test]
fn dragging_the_find_and_replace_title_bar_moves_the_dialog_and_it_stays_there() {
let mut h = harness();
h.state_mut().open_dialog("find", json!({}));
h.run_steps(6);
let before = dialog_rect(&h, "find");
drag_title_bar(&mut h, "find", vec2(120.0, 80.0));
h.run_steps(10);
let after = dialog_rect(&h, "find");
assert_eq!(after.min - before.min, vec2(120.0, 80.0), "dialog went from {before:?} to {after:?}");
assert!(h.state().dialog.is_some(), "the drag does not close the dialog");
}
#[test]
fn go_to_format_cells_and_sort_can_be_dragged_too() {
for name in ["goTo", "formatCells", "sort"] {
let mut h = harness();
h.state_mut().open_dialog(name, json!({}));
h.run_steps(6);
let before = dialog_rect(&h, name);
drag_title_bar(&mut h, name, vec2(-100.0, 60.0));
h.run_steps(10);
let after = dialog_rect(&h, name);
assert_eq!(after.min - before.min, vec2(-100.0, 60.0), "{name} went from {before:?} to {after:?}");
}
}
+18
View File
@@ -985,3 +985,21 @@ fn undo_after_backspace_restores_the_cleared_cell() {
assert_eq!(value(&h, "A1"), Value::Number(3200.0));
assert_eq!(value(&h, "C5"), Value::Number(11750.0));
}
#[test]
fn header_footer_dialog_opens_on_the_saved_header_and_footer() {
// #523: the dialog started from a fixed empty header and `&CPage &P of &N`, so OK replaced the sheet's text.
let mut s = blank();
s.execute("pageLayout.headerFooter", json!({"header": "&LLeft", "footer": "&CConfidential"})).unwrap();
let mut h = harness(s);
h.state_mut().open_dialog("headerFooter", json!({}));
h.run_steps(2);
let d = h.state_mut().dialog.as_mut().unwrap();
assert_eq!(d.values["header"], json!("&LLeft"));
assert_eq!(d.values["footer"], json!("&CConfidential"));
d.values.insert("header".into(), json!("&CNew"));
let vals = json!(d.values.clone());
h.state_mut().run("pageLayout.headerFooter", vals).unwrap();
let pr = &h.state().session.active().unwrap().wb.active().unwrap().print;
assert_eq!((pr.header.as_str(), pr.footer.as_str()), ("&CNew", "&CConfidential"));
}
+1 -1
View File
@@ -2157,7 +2157,7 @@ fn keytip_apt_() {
fn keytip_aq_() {
let mut h = harness(blank());
sequence(&mut h, "AQ", false);
assert!(h.state().message.is_some() || h.state().toast.is_some(), "AQ must invoke data.advancedFilter and surface its missing-params error");
assert!(h.state().dialog.is_some(), "AQ must open the advancedFilter dialog");
}
#[test]
@@ -0,0 +1,56 @@
//! Paste Special: options in two columns, translated, OK pastes with them and Paste Link links.
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness() -> Harness {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[2], [3]]})).unwrap();
s.execute("range.setValues", json!({"range": "C1", "values": [[10], [10]]})).unwrap();
s.execute("edit.copy", json!({"range": "A1:A2"})).unwrap();
s.execute("selection.set", json!({"cell": "C1"})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
SheetApp::new(s, Default::default()),
);
h.state_mut().open_dialog("pasteSpecial", json!({}));
h.run_steps(4);
h
}
fn cell(h: &mut Harness, a1: &str) -> serde_json::Value {
h.state_mut().session.execute("cell.get", json!({"cell": a1})).unwrap()
}
#[test]
fn an_operation_chosen_in_the_dialog_is_applied() {
let mut h = harness();
h.get_by_label("Values (V)").click();
h.get_by_label("Multiply").click();
h.run_steps(3);
h.get_by_label_contains("OK").click();
h.run_steps(3);
assert!(h.state().dialog.is_none());
assert_eq!(cell(&mut h, "C1")["value"], json!(20.0));
assert_eq!(cell(&mut h, "C2")["value"], json!(30.0));
}
#[test]
fn paste_link_pastes_references_at_once() {
let mut h = harness();
h.get_by_label("Paste Link").click();
h.run_steps(3);
assert!(h.state().dialog.is_none());
let c1 = cell(&mut h, "C1");
assert_eq!(c1["value"], json!(2.0));
assert!(c1["formula"].as_str().is_some_and(|f| f.contains("A1")), "{c1}");
}
@@ -0,0 +1,81 @@
//! #1906: the Sort dialog seeds "Sort by" from the active column instead of a
//! free-text box stuck on "A", and the column list offers the region's columns.
use egui::{Key, Modifiers};
use egui_kittest::kittest::Queryable;
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness(session: Session) -> Harness {
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
h.run_steps(4);
h
}
/// Header row + three rows; the user's cursor sits in column B (Revenue).
fn sales() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["Month", "Revenue"], ["Mar", 30], ["Jan", 10], ["Feb", 20]]})).unwrap();
s.execute("selection.set", json!({"range": "B2"})).unwrap();
s
}
fn col(h: &Harness) -> String {
h.state().dialog.as_ref().unwrap().values["levels"][0]["column"].as_str().unwrap_or("<missing>").to_string()
}
#[test]
fn sort_dialog_seeds_the_active_column() {
let mut h = harness(sales());
h.state_mut().open_dialog("sort", json!({}));
h.run_steps(2);
assert_eq!(col(&h), "B", "Sort by starts on the column the cursor is in, not A");
}
#[test]
fn add_level_picks_a_column_no_level_uses_yet() {
let mut h = harness(sales());
h.state_mut().open_dialog("sort", json!({}));
h.run_steps(2);
// Level 1 seeded B; clicking "+ Add Level" must not default level 2 to B too (#1906 review).
h.run_steps(6);
h.get_by_label("+ Add Level").click();
h.run_steps(6);
let cols: Vec<String> = h.state().dialog.as_ref().unwrap().values["levels"]
.as_array()
.map(|a| a.iter().filter_map(|l| l["column"].as_str().map(str::to_string)).collect())
.unwrap_or_default();
assert_eq!(cols.len(), 2, "two levels after Add Level");
assert_ne!(cols[0], cols[1], "the second level defaults to a different column: {cols:?}");
}
#[test]
fn sorting_by_the_seeded_column_orders_the_rows() {
let mut h = harness(sales());
h.state_mut().open_dialog("sort", json!({}));
h.run_steps(2);
// Accept the dialog: rows must come back ordered by Revenue (10, 20, 30).
h.input_mut().events.push(egui::Event::Key {
key: Key::Enter,
physical_key: Some(Key::Enter),
pressed: true,
repeat: false,
modifiers: Modifiers::NONE,
});
h.run_steps(2);
let d = h.state().session.active().unwrap();
let sh = d.wb.active().unwrap();
let nums: Vec<i64> = (1..4).map(|r| sh.value(gridcraft_engine::core::CellRef::new(r, 1))).filter_map(|v| v.as_f64().map(|n| n as i64)).collect();
assert_eq!(nums, vec![10, 20, 30], "sort ran on the seeded column B");
}
+62
View File
@@ -83,3 +83,65 @@ fn a_custom_percentage_after_a_magnification_wins_and_an_unedited_ok_keeps_the_z
control(&mut h, "ui.dialog.confirm", json!({}));
assert_eq!(zoom(&h), 225);
}
#[test]
fn the_current_zoom_is_preselected_as_its_magnification_or_as_custom() {
let mut h = harness();
h.state_mut().open_dialog("zoom", json!({}));
h.run_steps(2);
let d = h.state().dialog.clone().unwrap();
assert_eq!(d.values["preset"], json!("100"));
assert_eq!(d.values["percent"], json!(100));
control(&mut h, "ui.dialog.cancel", json!({}));
h.state_mut().run("view.zoom", json!({"percent": 130})).unwrap();
h.state_mut().open_dialog("zoom", json!({}));
h.run_steps(2);
let d = h.state().dialog.clone().unwrap();
assert_eq!(d.values["preset"], json!("custom"));
assert_eq!(d.values["percent"], json!(130));
// An unedited OK keeps the zoom.
let r = control(&mut h, "ui.dialog.confirm", json!({}));
assert_eq!(r["ok"], json!(true), "{r}");
assert_eq!(zoom(&h), 130);
}
#[test]
fn a_typed_percentage_selects_custom_and_sets_that_zoom() {
let mut h = harness();
control(&mut h, "ui.dialog", json!({"name": "zoom"}));
control(&mut h, "ui.dialog.set", json!({"field": "preset", "value": "fit"}));
control(&mut h, "ui.dialog.set", json!({"field": "percent", "value": 150}));
assert_eq!(h.state().dialog.as_ref().unwrap().values["preset"], json!("custom"));
let r = control(&mut h, "ui.dialog.confirm", json!({}));
assert_eq!(r["ok"], json!(true), "{r}");
assert_eq!(zoom(&h), 150);
}
#[test]
fn fit_selection_zooms_so_the_selected_cells_fill_the_grid() {
let mut h = harness();
h.state_mut().run("selection.set", json!({"range": "A1:J40"})).unwrap();
h.run_steps(2);
let view = h.state().grid.cells_rect.unwrap();
h.state_mut().open_dialog("zoom", json!({}));
h.run_steps(6);
h.get_by_label("Fit selection").click();
h.run_steps(6);
h.get_by_label(" OK ").click();
h.run_steps(6);
assert!(h.state().dialog.is_none());
// The same answer the ribbon's Zoom to Selection gives for this grid.
let mut expected = Session::new();
expected.new_workbook();
expected.execute("selection.set", json!({"range": "A1:J40"})).unwrap();
let r = expected.execute("view.zoomToSelection", json!({"viewWidth": view.width(), "viewHeight": view.height()})).unwrap();
assert_eq!(json!(zoom(&h)), r["zoom"]);
assert!(zoom(&h) > 10 && zoom(&h) < 100, "{}", zoom(&h));
// Fit selection over the control channel, before any frame is drawn, uses the grid size captured on opening.
h.state_mut().run("selection.set", json!({"range": "A1"})).unwrap();
control(&mut h, "ui.dialog", json!({"name": "zoom"}));
control(&mut h, "ui.dialog.set", json!({"field": "preset", "value": "fit"}));
let r = control(&mut h, "ui.dialog.confirm", json!({}));
assert_eq!(r["ok"], json!(true), "{r}");
assert_eq!(zoom(&h), 400);
}