mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-03 17:16:14 -06:00
Compare commits
94
Commits
@@ -31,20 +31,41 @@ fn is_volatile(e: &Expr, volatile_names: &HashSet<String>) -> bool {
|
||||
}
|
||||
|
||||
/// Upper-cased defined names whose formula is volatile, directly or through other names.
|
||||
///
|
||||
/// Volatility flows backwards along name references: each name is parsed and walked once, and a
|
||||
/// worklist carries volatility from a name to the names that use it. Rescanning every name until
|
||||
/// nothing changes was quadratic: a file listing its names so that each pass marked only one more
|
||||
/// did one full pass per name.
|
||||
pub(super) fn volatile_names(wb: &Workbook) -> HashSet<String> {
|
||||
let parsed: Vec<(String, Option<Expr>)> =
|
||||
wb.names.iter().map(|n| (n.name.to_ascii_uppercase(), gridcraft_engine::formula::parse(&n.formula).ok())).collect();
|
||||
let mut set = HashSet::new();
|
||||
// Each round adds at least one name or stops, so `len + 1` rounds reach the fixed point.
|
||||
for _ in 0..=parsed.len() {
|
||||
let before = set.len();
|
||||
for (name, e) in &parsed {
|
||||
if !set.contains(name) && e.as_ref().is_some_and(|e| is_volatile(e, &set)) {
|
||||
set.insert(name.clone());
|
||||
// Upper-cased name -> the names whose formulas refer to it.
|
||||
let mut users: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let mut queue = Vec::new();
|
||||
let none = HashSet::new();
|
||||
for n in &wb.names {
|
||||
let name = n.name.to_ascii_uppercase();
|
||||
let Ok(e) = gridcraft_engine::formula::parse(&n.formula) else { continue };
|
||||
if is_volatile(&e, &none) {
|
||||
if set.insert(name.clone()) {
|
||||
queue.push(name.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if set.len() == before {
|
||||
break;
|
||||
let mut used = BTreeSet::new();
|
||||
e.walk(&mut |x| {
|
||||
if let Expr::Name(r) = x {
|
||||
used.insert(r.to_ascii_uppercase());
|
||||
}
|
||||
});
|
||||
for r in used {
|
||||
users.entry(r).or_default().push(name.clone());
|
||||
}
|
||||
}
|
||||
while let Some(v) = queue.pop() {
|
||||
for user in users.remove(&v).unwrap_or_default() {
|
||||
if set.insert(user.clone()) {
|
||||
queue.push(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
set
|
||||
|
||||
@@ -178,6 +178,28 @@ fn volatile_names_propagate() {
|
||||
assert_eq!(check(&wb, &Options::default()).calc.volatile, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volatile_names_propagate_through_a_long_chain_in_one_walk() {
|
||||
// Chain0 uses Chain1, Chain1 uses Chain2, ... and only the last is volatile: listed in this order, a rescan
|
||||
// until nothing changes marked one name per pass (quadratic). Every name in the chain is
|
||||
// volatile; a name off the chain is not.
|
||||
const LEN: usize = 5_000;
|
||||
let mut wb = book(&[("A1", "=1", n(1.0))]);
|
||||
let name =
|
||||
|name: String, formula: String| gridcraft_engine::model::DefinedName { name, scope: None, formula, comment: String::new(), hidden: false };
|
||||
for i in 0..LEN {
|
||||
let formula = if i + 1 == LEN { "NOW()".to_string() } else { format!("Chain{}+1", i + 1) };
|
||||
wb.names.push(name(format!("Chain{i}"), formula));
|
||||
}
|
||||
wb.names.push(name("Plain".into(), "Chain0*0+Other".into()));
|
||||
wb.names.push(name("Other".into(), "1".into()));
|
||||
let set = volatile_names(&wb);
|
||||
assert!((0..LEN).all(|i| set.contains(&format!("CHAIN{i}"))));
|
||||
assert!(set.contains("PLAIN"));
|
||||
assert!(!set.contains("OTHER"));
|
||||
assert_eq!(set.len(), LEN + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_of_a_simple_book_is_clean() {
|
||||
let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]);
|
||||
|
||||
@@ -193,7 +193,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(file) = rfd::AsyncFileDialog::new()
|
||||
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"])
|
||||
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "slk", "json"])
|
||||
.pick_file()
|
||||
.await
|
||||
else {
|
||||
|
||||
@@ -37,13 +37,13 @@ winit = { version = "0.30", default-features = false, features = ["x11", "waylan
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true }
|
||||
arboard = { workspace = true, features = ["image-data"] }
|
||||
gridcraft-mcp = { workspace = true }
|
||||
# The control channel's token (OS random numbers); already in the tree.
|
||||
getrandom = "0.4"
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
|
||||
arboard = { workspace = true, features = ["wayland-data-control"] }
|
||||
arboard = { workspace = true, features = ["image-data", "wayland-data-control"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# Blocks on the start-up DirectX 12 adapter probe (`graphics.rs`); already in the tree via eframe.
|
||||
|
||||
@@ -154,7 +154,7 @@ fn read_clipboard<T>(
|
||||
fn services() -> Services {
|
||||
// Keep the owner alive: X11/Wayland serve clipboard data from this handle.
|
||||
let clipboard: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
|
||||
let (html_clipboard, text_clipboard) = (clipboard.clone(), clipboard.clone());
|
||||
let (html_clipboard, text_clipboard, image_clipboard) = (clipboard.clone(), clipboard.clone(), clipboard.clone());
|
||||
Services {
|
||||
copy_html: Some(Box::new(move |html, text| {
|
||||
let mut clipboard = clipboard.borrow_mut();
|
||||
@@ -165,9 +165,13 @@ fn services() -> Services {
|
||||
})),
|
||||
paste_html: Some(Box::new(move || read_clipboard(&html_clipboard, |clipboard| clipboard.get().html()))),
|
||||
paste_text: Some(Box::new(move || read_clipboard(&text_clipboard, |clipboard| clipboard.get_text()))),
|
||||
paste_image: Some(Box::new(move || {
|
||||
let image = read_clipboard(&image_clipboard, |clipboard| clipboard.get_image())?;
|
||||
image::RgbaImage::from_raw(u32::try_from(image.width).ok()?, u32::try_from(image.height).ok()?, image.bytes.into_owned())
|
||||
})),
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"])
|
||||
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "slk", "json"])
|
||||
.add_filter("Excel Workbook", &["xlsx", "xlsm"])
|
||||
.add_filter("Excel 97-2003 Workbook (import)", &["xls"])
|
||||
.add_filter("Excel Binary Workbook (data import)", &["xlsb"])
|
||||
|
||||
@@ -359,9 +359,10 @@ impl<'h> Evaluator<'h> {
|
||||
return v.clone();
|
||||
}
|
||||
let wb = self.host.workbook();
|
||||
// Sheet-qualified `Sheet1!Name`.
|
||||
let (scope_sheet, bare) = match n.split_once('!') {
|
||||
Some((s, b)) => match wb.sheet_index(&gridcraft_core::unquote_sheet(s)) {
|
||||
// Sheet-qualified `Sheet1!Name`. The parser stores the sheet unquoted, so it is used as
|
||||
// is (`' Sales '` keeps its spaces) and split at the last `!` (`'a!b'!Name`).
|
||||
let (scope_sheet, bare) = match n.rsplit_once('!') {
|
||||
Some((s, b)) => match wb.sheet_index(s) {
|
||||
Some(i) => (i, b),
|
||||
None => return err(CellError::Name),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range};
|
||||
use super::legend::{cat_label, plot_kind};
|
||||
use super::lines::{decimate, draw_markers, draw_runs};
|
||||
use super::lines::{decimate_area, draw_markers, draw_runs};
|
||||
use super::trend::{draw_trend_labels, draw_trendlines, max_backward, max_forward, max_forward_slots, supports_trendline};
|
||||
use super::{
|
||||
Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_LINE_PTS, MAX_MARKERS, Prim, Scale, SeriesData, TEXT,
|
||||
@@ -268,10 +268,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
base = vec![[b[0] - cw * 0.3, b[1]], [b[0] + cw * 0.3, b[1]]];
|
||||
}
|
||||
}
|
||||
let top = decimate(top, MAX_LINE_PTS * 2);
|
||||
let base = decimate(base, MAX_LINE_PTS * 2);
|
||||
let mut poly = top;
|
||||
poly.extend(base.into_iter().rev());
|
||||
let poly = decimate_area(top, base, MAX_LINE_PTS * 2);
|
||||
if poly.len() >= 3 {
|
||||
ctx.out.push(Prim::Polygon { pts: poly, fill: s.color, stroke: None });
|
||||
}
|
||||
|
||||
@@ -54,6 +54,86 @@ pub(super) fn decimate(pts: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Decimates the top and baseline of an area chart together at the same horizontal indices so
|
||||
/// `area_mesh`'s paired vertical-strip topology (`pts[i]` ↔ `pts[2n - 1 - i]`) is preserved.
|
||||
pub(super) fn decimate_area(top: Vec<[f32; 2]>, base: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> {
|
||||
let n = top.len().min(base.len());
|
||||
if n == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
if n <= max {
|
||||
let mut poly = top;
|
||||
poly.truncate(n);
|
||||
poly.extend(base.into_iter().take(n).rev());
|
||||
return poly;
|
||||
}
|
||||
let mut cols: Vec<[usize; 6]> = Vec::new();
|
||||
let mut keep = Vec::with_capacity(max * 2);
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
let col = top[i][0].floor();
|
||||
let (start, mut t_mn, mut t_mx, mut b_mn, mut b_mx) = (i, i, i, i, i);
|
||||
let mut k = i + 1;
|
||||
while k < n && top[k][0].floor() == col {
|
||||
if top[k][1] < top[t_mn][1] {
|
||||
t_mn = k;
|
||||
}
|
||||
if top[k][1] > top[t_mx][1] {
|
||||
t_mx = k;
|
||||
}
|
||||
if base[k][1] < base[b_mn][1] {
|
||||
b_mn = k;
|
||||
}
|
||||
if base[k][1] > base[b_mx][1] {
|
||||
b_mx = k;
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
let entry = [start, t_mn, t_mx, b_mn, b_mx, k - 1];
|
||||
cols.push(entry);
|
||||
let mut idx = entry;
|
||||
idx.sort_unstable();
|
||||
let mut last = usize::MAX;
|
||||
for ix in idx {
|
||||
if ix != last {
|
||||
keep.push(ix);
|
||||
last = ix;
|
||||
}
|
||||
}
|
||||
i = k;
|
||||
}
|
||||
if keep.len() > max * 4 {
|
||||
let max_groups = (max * 4 / 6).max(1);
|
||||
let group_size = cols.len().div_ceil(max_groups);
|
||||
keep.clear();
|
||||
for ch in cols.chunks(group_size) {
|
||||
let start = ch[0][0];
|
||||
let end = ch.last().map_or(start, |c| c[5]);
|
||||
let t_mn = ch.iter().map(|c| c[1]).min_by(|&a, &b| top[a][1].total_cmp(&top[b][1])).unwrap_or(start);
|
||||
let t_mx = ch.iter().map(|c| c[2]).max_by(|&a, &b| top[a][1].total_cmp(&top[b][1])).unwrap_or(start);
|
||||
let b_mn = ch.iter().map(|c| c[3]).min_by(|&a, &b| base[a][1].total_cmp(&base[b][1])).unwrap_or(start);
|
||||
let b_mx = ch.iter().map(|c| c[4]).max_by(|&a, &b| base[a][1].total_cmp(&base[b][1])).unwrap_or(start);
|
||||
let mut idx = [start, t_mn, t_mx, b_mn, b_mx, end];
|
||||
idx.sort_unstable();
|
||||
let mut last = usize::MAX;
|
||||
for ix in idx {
|
||||
if ix != last {
|
||||
keep.push(ix);
|
||||
last = ix;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut poly = Vec::with_capacity(keep.len() * 2);
|
||||
for &ix in &keep {
|
||||
poly.push(top[ix]);
|
||||
}
|
||||
for &ix in keep.iter().rev() {
|
||||
poly.push(base[ix]);
|
||||
}
|
||||
poly
|
||||
}
|
||||
|
||||
/// Draws a polyline split at gaps (`None`), decimated when long. With `smooth` (the plot box),
|
||||
/// each run is interpolated with a Catmull-Rom spline so the line curves through the data points.
|
||||
pub(super) fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32, smooth: Option<Bx>) {
|
||||
@@ -155,4 +235,57 @@ mod smooth_tests {
|
||||
assert!(out.len() > 5);
|
||||
assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimate_area_preserves_paired_vertical_strips() {
|
||||
let top: Vec<[f32; 2]> = (0..1000).map(|i| [i as f32 * 0.25, ((i * 37) % 100) as f32]).collect();
|
||||
let base: Vec<[f32; 2]> = (0..1000).map(|i| [i as f32 * 0.25, 100.0]).collect();
|
||||
let poly = decimate_area(top, base, 64);
|
||||
assert_eq!(poly.len() % 2, 0);
|
||||
let half = poly.len() / 2;
|
||||
for i in 0..half {
|
||||
assert_eq!(poly[i][0], poly[poly.len() - 1 - i][0]);
|
||||
assert_eq!(poly[poly.len() - 1 - i][1], 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimate_area_preserves_extrema_across_3000_columns() {
|
||||
// 3,000 occupied pixel columns with 6 points per column (18,000 points total),
|
||||
// where each column has distinct top and baseline extrema so the initial per-column
|
||||
// index list exceeds `MAX_LINE_PTS * 2 * 4` (6,400).
|
||||
let max = MAX_LINE_PTS * 2;
|
||||
let mut top = Vec::with_capacity(18_000);
|
||||
let mut base = Vec::with_capacity(18_000);
|
||||
for col in 0..3_000 {
|
||||
let y_top = [50.0, 20.0, 80.0, 50.0, 50.0, 50.0];
|
||||
let y_base = [150.0, 150.0, 150.0, 120.0, 180.0, 150.0];
|
||||
for s in 0..6 {
|
||||
let x = col as f32 + s as f32 * 0.1;
|
||||
top.push([x, y_top[s]]);
|
||||
base.push([x, y_base[s]]);
|
||||
}
|
||||
}
|
||||
// Plant sharp spikes on both top and baseline in interior columns.
|
||||
top[777 * 6 + 1][1] = 1.0;
|
||||
top[1999 * 6 + 2][1] = 99.0;
|
||||
base[1234 * 6 + 3][1] = 101.0;
|
||||
base[2501 * 6 + 4][1] = 199.0;
|
||||
|
||||
let poly = decimate_area(top, base, max);
|
||||
assert_eq!(poly.len() % 2, 0);
|
||||
let half = poly.len() / 2;
|
||||
assert!(half <= max * 4, "retained {half} paired vertices, limit is {}", max * 4);
|
||||
for i in 0..half {
|
||||
assert_eq!(poly[i][0], poly[poly.len() - 1 - i][0]);
|
||||
}
|
||||
let top_kept = &poly[..half];
|
||||
let base_kept = &poly[half..];
|
||||
assert_eq!(top_kept.first().map(|p| p[0]), Some(0.0));
|
||||
assert_eq!(top_kept.last().map(|p| p[0]), Some(2999.5));
|
||||
assert!(top_kept.iter().any(|p| p[1] == 1.0), "top min spike preserved");
|
||||
assert!(top_kept.iter().any(|p| p[1] == 99.0), "top max spike preserved");
|
||||
assert!(base_kept.iter().any(|p| p[1] == 101.0), "base min spike preserved");
|
||||
assert!(base_kept.iter().any(|p| p[1] == 199.0), "base max spike preserved");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,9 +179,10 @@ fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (
|
||||
/// Whether `t` names a defined name seen from `sheet` (`Name`, or `Sheet1!Name` looked up on
|
||||
/// that sheet), the way the evaluator looks names up.
|
||||
fn is_defined_name(wb: &Workbook, sheet: usize, t: &str) -> bool {
|
||||
match t.split_once('!') {
|
||||
Some((s, bare)) => wb.sheet_index(s.trim_matches('\'')).is_some_and(|i| wb.name(bare, i).is_some()),
|
||||
None => wb.name(t, sheet).is_some(),
|
||||
// `'Bob''s'!Name`: the sheet is unquoted as everywhere else (#2012).
|
||||
match gridcraft_core::split_sheet(t) {
|
||||
(Some(s), bare) => wb.sheet_index(&s).is_some_and(|i| wb.name(bare, i).is_some()),
|
||||
(None, _) => wb.name(t, sheet).is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1258,3 +1258,75 @@ fn box_whisker_draws_each_series_quartile_method() {
|
||||
// The boxes span 3.5 and 2.5 data units on the same value axis.
|
||||
assert!((heights[0] / heights[1] - 1.4).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_series_named_by_a_name_on_a_sheet_with_an_apostrophe_resolves() {
|
||||
// #2012: `'Bob''s'!Title` kept the doubled quote, so the sheet wasn't found and the series
|
||||
// was named by the literal text.
|
||||
let mut wb = Workbook::new();
|
||||
{
|
||||
let s = wb.sheet_mut(0).unwrap();
|
||||
s.name = "Bob's".into();
|
||||
s.set_value(CellRef::new(0, 0), Value::from("Takings"));
|
||||
s.set_value(CellRef::new(1, 0), Value::Number(4.0));
|
||||
s.set_value(CellRef::new(2, 0), Value::Number(9.0));
|
||||
}
|
||||
wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "Title".into(),
|
||||
scope: Some(0),
|
||||
formula: "'Bob''s'!$A$1".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.series = vec![Series {
|
||||
name: Some("'Bob''s'!Title".into()),
|
||||
categories: None,
|
||||
values: "'Bob''s'!$A$2:$A$3".into(),
|
||||
bubble_sizes: None,
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
}];
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.series.first().map(|s| s.name.as_str()), Some("Takings"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_series_named_by_a_name_on_a_sheet_with_a_bang_resolves() {
|
||||
// `'a!b'!Title`: the sheet name holds a `!`, so the evaluator must split the parsed name at
|
||||
// the last one, not the first.
|
||||
let mut wb = Workbook::new();
|
||||
{
|
||||
let s = wb.sheet_mut(0).unwrap();
|
||||
s.name = "a!b".into();
|
||||
s.set_value(CellRef::new(0, 0), Value::from("Takings"));
|
||||
s.set_value(CellRef::new(1, 0), Value::Number(4.0));
|
||||
s.set_value(CellRef::new(2, 0), Value::Number(9.0));
|
||||
}
|
||||
wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "Title".into(),
|
||||
scope: Some(0),
|
||||
formula: "'a!b'!$A$1".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.series = vec![Series {
|
||||
name: Some("'a!b'!Title".into()),
|
||||
categories: None,
|
||||
values: "'a!b'!$A$2:$A$3".into(),
|
||||
bubble_sizes: None,
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
}];
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.series.first().map(|s| s.name.as_str()), Some("Takings"));
|
||||
}
|
||||
|
||||
@@ -188,6 +188,12 @@ impl RangeRef {
|
||||
end: CellRef::new(self.end.row.max(o.end.row), self.end.col.max(o.end.col)),
|
||||
}
|
||||
}
|
||||
/// The top-left cell of a list of areas: the lowest row and the lowest column across all of
|
||||
/// them, whatever order they are listed in. Excel writes the relative formulas of a
|
||||
/// multi-area validation or conditional format for this cell. `None` for no areas.
|
||||
pub fn top_left(ranges: &[RangeRef]) -> Option<CellRef> {
|
||||
ranges.iter().copied().reduce(|a, b| a.union(&b)).map(|r| r.start)
|
||||
}
|
||||
/// Row-major iterator over the cells. Callers must bound huge ranges themselves.
|
||||
pub fn iter(&self) -> impl Iterator<Item = CellRef> + '_ {
|
||||
let (r0, r1, c0, c1) = (self.start.row, self.end.row, self.start.col, self.end.col);
|
||||
@@ -307,6 +313,16 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_left_spans_every_area_in_any_order() {
|
||||
let areas: Vec<RangeRef> = ["H62:H68", "H26:H27", "H93:H95"].iter().filter_map(|a| RangeRef::parse(a)).collect();
|
||||
assert_eq!(RangeRef::top_left(&areas), CellRef::parse("H26"));
|
||||
// Lowest row and lowest column, even when they come from different areas.
|
||||
let areas: Vec<RangeRef> = ["C5:D6", "E2", "B9"].iter().filter_map(|a| RangeRef::parse(a)).collect();
|
||||
assert_eq!(RangeRef::top_left(&areas), CellRef::parse("B2"));
|
||||
assert_eq!(RangeRef::top_left(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_quoting_follows_excel() {
|
||||
for quoted in [
|
||||
|
||||
@@ -244,7 +244,7 @@ impl CfCache {
|
||||
let v = sh.value(c);
|
||||
let no_stats = RuleStats::default();
|
||||
for (idx, cf) in rules {
|
||||
let first = cf.ranges.first().map(|r| r.start).unwrap_or(c);
|
||||
let first = RangeRef::top_left(&cf.ranges).unwrap_or(c);
|
||||
let st: &RuleStats = match stats_need(&cf.rule) {
|
||||
Some(need) => &*self.stats.entry(idx).or_insert_with(|| RuleStats::build(sh, &cf.ranges, need)),
|
||||
None => &no_stats,
|
||||
|
||||
@@ -17,11 +17,13 @@ const MAX_ROW_SPAN: usize = gridcraft_core::MAX_ROWS as usize;
|
||||
/// table is ignored: the HTML only refines cells of the tab-separated text, which stays the source.
|
||||
const MAX_HTML_CELLS: usize = 1_000_000;
|
||||
|
||||
/// One table cell: its visible text and the number the source recorded for it, if any.
|
||||
/// One table cell: its visible text, the number the source recorded for it, if any, and whether
|
||||
/// it is a header cell (`<th>`).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct HtmlCell {
|
||||
pub text: String,
|
||||
pub num: Option<f64>,
|
||||
pub header: bool,
|
||||
}
|
||||
|
||||
/// The cells of the first table in `html`, row by row, read no further than `max_rows` rows of
|
||||
@@ -88,7 +90,7 @@ pub(crate) fn table_cells(html: &str, max_rows: usize, max_cols: usize) -> Vec<V
|
||||
let num = attr(attrs, "x:num").or_else(|| attr(attrs, "sdval")).and_then(|v| v.trim().parse::<f64>().ok()).filter(|n| n.is_finite());
|
||||
let colspan = span(attrs, "colspan", MAX_SPAN);
|
||||
let rowspan = span(attrs, "rowspan", MAX_ROW_SPAN);
|
||||
cell = Some(OpenCell { cell: HtmlCell { text: String::new(), num }, colspan, rowspan });
|
||||
cell = Some(OpenCell { cell: HtmlCell { text: String::new(), num, header: name == "th" }, colspan, rowspan });
|
||||
}
|
||||
("td" | "th", true) | ("tr", true) => grid.finish(&mut cell),
|
||||
("br", _) => {
|
||||
@@ -376,18 +378,18 @@ mod tests {
|
||||
<tr><td colspan=2 x:num="2.5">2.50 & up</td><td>x</td></tr><!--EndFragment--></table></body></html>"#;
|
||||
let rows = table_cells(excel, 2, 3);
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) });
|
||||
assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None });
|
||||
assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0), ..HtmlCell::default() });
|
||||
assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None, ..HtmlCell::default() });
|
||||
assert_eq!(rows[1].len(), 3, "colspan keeps the next cell in its column");
|
||||
assert_eq!(rows[1][0].text, "2.50 & up");
|
||||
assert_eq!(rows[1][2].text, "x");
|
||||
|
||||
let calc = r#"<table cellspacing="0" border="0"><colgroup width="85"></colgroup><tr>
|
||||
<td height="17" align="right" sdval="10000" sdnum="1033;0;[$Rp-421]#,##0.00"><font face="Liberation Sans">Rp10,000.00</font></td></tr></table>"#;
|
||||
assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]);
|
||||
assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0), ..HtmlCell::default() }]]);
|
||||
|
||||
let quoted_gt = r#"<table><tr><td style='mso-number-format:"\[>=1000\]0"' x:num="1500">1500</td></tr></table>"#;
|
||||
assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
|
||||
assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0), ..HtmlCell::default() }]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -434,7 +436,7 @@ mod tests {
|
||||
assert!(cells <= MAX_HTML_CELLS, "{cells} cells");
|
||||
assert!(rows.iter().all(|r| r.len() <= MAX_SPAN));
|
||||
assert_eq!(rows[0].len(), MAX_SPAN, "the first span fills the sheet's width");
|
||||
assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0) });
|
||||
assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0), ..HtmlCell::default() });
|
||||
|
||||
// Bounded by the pasted text's shape, the same markup yields just that shape.
|
||||
let rows = table_cells(&html, 3, 2);
|
||||
|
||||
@@ -459,8 +459,11 @@ pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000;
|
||||
pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet.";
|
||||
|
||||
/// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or
|
||||
/// formula), whichever command made it. Sheets are matched by name, so moving, renaming or
|
||||
/// deleting a sheet isn't an edit of its cells.
|
||||
/// formula), or that unlocked it, whichever command made it. Sheets are matched by name, so
|
||||
/// moving, renaming or deleting a sheet isn't an edit of its cells. A sort or duplicate removal
|
||||
/// that moves an unlocked cell with equal content onto a locked one changes no content but would
|
||||
/// leave the cell editable, so the effective lock (row and column formats included) is compared
|
||||
/// as well.
|
||||
fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)]) -> Result<()> {
|
||||
for sh in &old.sheets {
|
||||
if !sh.is_protected() {
|
||||
@@ -471,7 +474,8 @@ fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)
|
||||
continue;
|
||||
}
|
||||
for c in sh.cells.diff(&after.cells) {
|
||||
if !same_content(sh.cell(c), after.cell(c))
|
||||
let unlocked_now = !new.styles.get(after.style_id(c)).protection.locked;
|
||||
if (unlocked_now || !same_content(sh.cell(c), after.cell(c)))
|
||||
&& old.styles.get(sh.style_id(c)).protection.locked
|
||||
&& let Some(m) = locked_cell_error(sh, c, unlocked)
|
||||
{
|
||||
|
||||
@@ -480,6 +480,45 @@ fn a_list_typed_without_quotes_is_saved_as_a_quoted_list() {
|
||||
assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relative_list_source_is_read_from_the_cell_being_checked() {
|
||||
// As in Excel, `=$B1:$B2` on A3:A4,A1:A2 is written for A1 (the top-left cell of all the
|
||||
// areas) and lists B4:B5 at A4. It used to list B1:B2 in every cell.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "B1:B5", "values": [["a"], ["b"], ["c"], ["d"], ["e"]]})).unwrap();
|
||||
s.execute("data.validation", json!({"range": "A3:A4,A1:A2", "type": "list", "formula1": "=$B1:$B2"})).unwrap();
|
||||
let wb = s.doc().unwrap().wb.clone();
|
||||
let dv = wb.active().unwrap().validations[0].clone();
|
||||
for (cell, items) in [("A1", ["a", "b"]), ("A2", ["b", "c"]), ("A3", ["c", "d"]), ("A4", ["d", "e"])] {
|
||||
let at = CellRef::parse(cell).unwrap();
|
||||
assert_eq!(list_items(&wb, 0, at, &dv), items, "{cell}");
|
||||
for v in ["a", "b", "c", "d", "e"] {
|
||||
assert_eq!(check_validation(&wb, 0, at, v).is_none(), items.contains(&v), "{cell} with {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_source_that_takes_in_the_cell_does_not_list_the_value_being_typed() {
|
||||
// `=$A1:$A2` on A2:A3 is A1:A2 at A2 and A2:A3 at A3. The list is the one before the entry, as
|
||||
// in Excel; a new value used to land in its own source and pass.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1:A2", "values": [["x"], ["y"]]})).unwrap();
|
||||
s.execute("data.validation", json!({"range": "A2:A3", "type": "list", "formula1": "=$A1:$A2"})).unwrap();
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
for (cell, accepted, refused) in [("A2", &["x", "y"][..], &["new"][..]), ("A3", &["y"], &["x", "new"])] {
|
||||
let at = CellRef::parse(cell).unwrap();
|
||||
for v in accepted {
|
||||
assert!(check_validation(wb, 0, at, v).is_none(), "{cell} refuses {v}");
|
||||
}
|
||||
for v in refused {
|
||||
assert!(check_validation(wb, 0, at, v).is_some(), "{cell} accepts {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rule_with_an_unknown_type_operator_or_error_style_is_refused() {
|
||||
// A typo used to give a rule that validates nothing (`type="none"`) or the wrong check,
|
||||
|
||||
@@ -38,11 +38,18 @@ fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec<RangeRef> {
|
||||
}
|
||||
|
||||
/// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges.
|
||||
/// When the rule's top-left cell goes, its relative formulas are rewritten for the new top-left
|
||||
/// cell, so the cells left keep checking the same cells.
|
||||
pub(in super::super) fn remove_validation_cells(validations: &mut Vec<Validation>, cuts: &[RangeRef]) {
|
||||
for dv in validations.iter_mut() {
|
||||
let before = RangeRef::top_left(&dv.ranges);
|
||||
for cut in cuts {
|
||||
dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect();
|
||||
}
|
||||
if let (Some(from), Some(to)) = (before, RangeRef::top_left(&dv.ranges)) {
|
||||
dv.f1 = move_rule_formula(&dv.f1, from, to);
|
||||
dv.f2 = dv.f2.take().map(|f| move_rule_formula(&f, from, to));
|
||||
}
|
||||
}
|
||||
validations.retain(|d| !d.ranges.is_empty());
|
||||
}
|
||||
@@ -145,8 +152,10 @@ fn list_source(f: &str) -> String {
|
||||
format!("\"{}\"", t.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
/// List entries of a list validation (literal or from a range).
|
||||
pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec<String> {
|
||||
/// List entries of a list validation (literal or from a range) for the cell `at`. A relative
|
||||
/// source is written for the rule's top-left cell and read from `at`, as in Excel (`=$B1:$B3` on
|
||||
/// A1:A9 lists B5:B7 at A5).
|
||||
pub fn list_items(wb: &Workbook, sheet: usize, at: CellRef, dv: &Validation) -> Vec<String> {
|
||||
let f = dv.f1.trim();
|
||||
if is_literal_list(f) {
|
||||
// A quoted list is a string literal: `""` inside stands for one `"`.
|
||||
@@ -156,8 +165,8 @@ pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec<String> {
|
||||
};
|
||||
return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
|
||||
}
|
||||
let at = dv.ranges.first().map(|r| r.start).unwrap_or_default();
|
||||
match gridcraft_calc::evaluate(wb, sheet, at, f) {
|
||||
let first = RangeRef::top_left(&dv.ranges).unwrap_or(at);
|
||||
match gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at)) {
|
||||
Value::Array(a) => a.data.iter().filter(|v| !v.is_empty()).map(Value::display).collect(),
|
||||
v if !v.is_empty() && !v.is_error() => vec![v.display()],
|
||||
_ => vec![],
|
||||
@@ -175,9 +184,10 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) -
|
||||
return None;
|
||||
}
|
||||
let v = gridcraft_core::parse::parse_input(input, wb.date_system).value;
|
||||
// The formulas are written for the first cell of the range and see the value being entered,
|
||||
// The formulas are written for the top-left cell of all the areas and see the value being entered,
|
||||
// as in Excel (a custom `=A1>10` on A1:A9 checks A5's new value at A5).
|
||||
let first = dv.ranges.first().map(|r| r.start).unwrap_or(at);
|
||||
let first = RangeRef::top_left(&dv.ranges).unwrap_or(at);
|
||||
let original = wb;
|
||||
let entered;
|
||||
let wb = if sh.value(at) == v {
|
||||
wb
|
||||
@@ -193,7 +203,9 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) -
|
||||
let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at));
|
||||
let num = |f: &str| eval(f).to_number().ok();
|
||||
let ok = match dv.kind {
|
||||
ValidationKind::List => list_items(wb, sheet, dv).iter().any(|x| x.eq_ignore_ascii_case(input)),
|
||||
// The list is the one the dropdown showed before the entry, as in Excel: a source that takes
|
||||
// in the cell itself (`=$A1:$A2` on A2:A3 is A2:A3 at A3) doesn't list the value being typed.
|
||||
ValidationKind::List => list_items(original, sheet, at, dv).iter().any(|x| x.eq_ignore_ascii_case(input)),
|
||||
ValidationKind::Custom => eval(&dv.f1).to_bool().unwrap_or(false),
|
||||
ValidationKind::TextLength => {
|
||||
let n = input.chars().count() as f64;
|
||||
|
||||
@@ -23,7 +23,7 @@ use cell::{cell_set, range_fill, range_set_values};
|
||||
pub(crate) use cell::{check_arg_counts, input_to_cell, is_formula_input, json_to_input, parse_typed_formula};
|
||||
#[cfg(test)]
|
||||
pub(crate) use clipboard::parse_tsv;
|
||||
use clipboard::{clear_clipboard, copy, cut, paste};
|
||||
use clipboard::{clear_clipboard, copy, cut, paste, range_move};
|
||||
use fill::{auto_fill, fill_dir, fill_series, flash_fill};
|
||||
#[cfg(test)]
|
||||
use find::replace_ci;
|
||||
@@ -36,7 +36,7 @@ use go_to::go_to_special;
|
||||
use go_to::{GO_TO_MAX_AREAS, GO_TO_RECENT, reference_parts};
|
||||
use paste_special::paste_special;
|
||||
pub(crate) use paste_special::{rewrite_all_formulas, rewrite_cell_links, rewrite_python_refs};
|
||||
use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_set};
|
||||
use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_next_corner, selection_set};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -62,6 +62,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "selection.set", "Select", [], None, "{range: \"A1:B2,D4\" | cell, active?: \"A1\", sheet?}", has_doc, selection_set),
|
||||
cmd!(noundo "selection.move", "Move Selection", [], None, "{dr, dc, extend?: bool, jump?: bool (Ctrl+arrow), page?: bool}", has_doc, selection_move),
|
||||
cmd!(noundo "selection.next", "Next Cell in Selection", [], None, "{forward?: true, byRow?: false} (Enter/Tab inside a selection)", has_doc, selection_next),
|
||||
cmd!(
|
||||
noundo "selection.nextCorner",
|
||||
"Next Corner of Selection",
|
||||
[],
|
||||
Some("Ctrl+."),
|
||||
"{} (the active cell moves clockwise to the next corner of the selected area, which stays selected)",
|
||||
has_doc,
|
||||
selection_next_corner
|
||||
),
|
||||
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} (current region first, then the whole sheet)", has_doc, select_all),
|
||||
cmd!(noundo "selection.currentRegion", "Select Current Region", [], Some("Ctrl+Shift+8"), "{}", has_doc, select_region),
|
||||
cmd!(noundo "selection.row", "Select Entire Row", [], Some("Shift+Space"), "{}", has_doc, select_row),
|
||||
@@ -81,6 +90,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
paste
|
||||
),
|
||||
cmd!(
|
||||
"range.move",
|
||||
"Drag and Drop",
|
||||
[],
|
||||
None,
|
||||
"{range?, to: \"C3\", overwrite?: bool} (drag the selection's border: a cut and paste that asks before replacing data unless overwrite)",
|
||||
has_doc,
|
||||
range_move
|
||||
),
|
||||
CommandSpec {
|
||||
shortcut_mac: Some("Ctrl+Cmd+V"),
|
||||
..cmd!(
|
||||
@@ -126,13 +144,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fill_series
|
||||
),
|
||||
cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Ctrl+E"), "{range?}", has_doc, flash_fill),
|
||||
cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook}", has_doc, find),
|
||||
cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook|selection} (selection: only the selected cells; Find Next keeps them selected and moves the active cell)", has_doc, find),
|
||||
cmd!(
|
||||
"edit.replace",
|
||||
"Replace…",
|
||||
["Home", "Editing", "Find & Select"],
|
||||
Some("Ctrl+H"),
|
||||
"{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)",
|
||||
"{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook|selection} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)",
|
||||
has_doc,
|
||||
replace
|
||||
),
|
||||
@@ -244,21 +262,36 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
})
|
||||
})
|
||||
};
|
||||
// Notes and links aren't cell contents, so the cell comparison that guards a protected sheet
|
||||
// doesn't see them go: a locked cell's are refused here, stored cell or not.
|
||||
if matches!(what, "all" | "comments" | "hyperlinks") {
|
||||
let d = s.doc()?;
|
||||
if let Some(sh) = d.wb.sheet(sheet).filter(|sh| sh.is_protected()) {
|
||||
for &whole in &ranges {
|
||||
let skip = filtered_rows(sh, whole);
|
||||
let notes = matches!(what, "all" | "comments").then(|| sh.comments.keys()).into_iter().flatten();
|
||||
let links = matches!(what, "all" | "hyperlinks").then(|| sh.hyperlinks.keys()).into_iter().flatten();
|
||||
for &c in notes.chain(links).filter(|c| whole.contains(**c) && !skip.contains(&c.row)) {
|
||||
if d.wb.styles.get(sh.style_id(c)).protection.locked
|
||||
&& let Some(m) = locked_cell_error(sh, c, &d.unlocked_ranges)
|
||||
{
|
||||
return Err(EngineError::Other(m));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
edit(s, |cx| {
|
||||
for r in &ranges {
|
||||
let r = match cx.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
|
||||
Some(u) => match r.intersection(&u) {
|
||||
Some(x) => x,
|
||||
None => continue,
|
||||
},
|
||||
None => continue,
|
||||
for &whole in &ranges {
|
||||
// Cells are only stored inside the used range. Notes and links are kept apart from
|
||||
// them, so they are cleared over the whole range: one from a file can sit on a cell
|
||||
// with nothing stored in it.
|
||||
let used = cx.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| whole.intersection(&u));
|
||||
let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, whole)).unwrap_or_default();
|
||||
let cells: Vec<(CellRef, Cell)> = match (cx.wb.sheet(sheet), used) {
|
||||
(Some(sh), Some(r)) => sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, r)).unwrap_or_default();
|
||||
let cells: Vec<(CellRef, Cell)> = cx
|
||||
.wb
|
||||
.sheet(sheet)
|
||||
.map(|sh| sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect())
|
||||
.unwrap_or_default();
|
||||
// Report every stored cell we touch, even beyond a million coordinates in a sparse
|
||||
// selection. Removed formula anchors must also reach calc so their spills disappear.
|
||||
cx.changed.extend(cells.iter().map(|(c, _)| (sheet, *c)));
|
||||
@@ -278,6 +311,18 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let cleared = |c: CellRef| whole.contains(c) && !skip.contains(&c.row);
|
||||
// A removed note or link changes its cell even with nothing stored there.
|
||||
let metadata: Vec<CellRef> = cx
|
||||
.wb
|
||||
.sheet(sheet)
|
||||
.map(|sh| {
|
||||
let notes = matches!(what, "all" | "comments").then(|| sh.comments.keys()).into_iter().flatten();
|
||||
let links = matches!(what, "all" | "hyperlinks").then(|| sh.hyperlinks.keys()).into_iter().flatten();
|
||||
notes.chain(links).copied().filter(|c| cleared(*c)).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
cx.changed.extend(metadata.into_iter().map(|c| (sheet, c)));
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, mut cell) in cells {
|
||||
match what {
|
||||
@@ -294,14 +339,15 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let cleared = |c: CellRef| r.contains(c) && !skip.contains(&c.row);
|
||||
if matches!(what, "all" | "comments") {
|
||||
sh.comments.retain(|c, _| !cleared(*c));
|
||||
}
|
||||
if matches!(what, "all" | "hyperlinks") {
|
||||
sh.hyperlinks.retain(|c, _| !cleared(*c));
|
||||
}
|
||||
if matches!(what, "all" | "formats") {
|
||||
if let Some(r) = used
|
||||
&& matches!(what, "all" | "formats")
|
||||
{
|
||||
let mut unmerged = false;
|
||||
sh.merges.retain(|m| {
|
||||
let keep = !r.contains_range(m) || skip.range(m.start.row..=m.end.row).next().is_some();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use super::cell::input_to_cell;
|
||||
use super::paste_special::paste_special;
|
||||
use super::{
|
||||
Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, cell_param_on, edit, json, ok, str_param, target_ranges, target_sheet,
|
||||
Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, bad, bool_param, cell_param_on, edit, json, ok, str_param, target_ranges,
|
||||
target_sheet,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- clipboard
|
||||
@@ -163,6 +164,40 @@ pub(super) fn paste(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
paste_special(s, &json!({"what": "all", "at": p.get("at").cloned().unwrap_or(Json::Null)}))
|
||||
}
|
||||
|
||||
/// Drag and drop (dragging the selection's border): moves `range` so its top-left cell lands on
|
||||
/// `to`, as Cut and Paste would. Like Excel, it asks before replacing cells that hold data or show a
|
||||
/// spilled value (other than the moved cells themselves); run it again with `overwrite: true` to replace them. A
|
||||
/// refused or failed move leaves the clipboard as it was.
|
||||
pub(super) fn range_move(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let to = cell_param_on(s, p, "to", sheet)?.ok_or_else(|| bad("range.move", "missing `to`"))?;
|
||||
let kept = s.clipboard.take();
|
||||
let moved = cut(s, p).and_then(|_| {
|
||||
let src = s.clipboard.as_ref().map(|c| c.range).unwrap_or_default();
|
||||
let dest = to.offset(i64::from(src.height()) - 1, i64::from(src.width()) - 1).map(|end| RangeRef::new(to, end));
|
||||
if bool_param(p, "overwrite") != Some(true)
|
||||
&& let Some(dest) = dest
|
||||
&& let Some(sh) = s.doc()?.wb.sheet(sheet)
|
||||
&& (sh.cells.iter_range(dest).any(|(c, cell)| !src.contains(c) && cell.has_content())
|
||||
|| sh.spill.range(dest.start..=dest.end).any(|(c, v)| dest.contains(*c) && !src.contains(*c) && *v != Value::Empty))
|
||||
{
|
||||
return Err(EngineError::Confirm("There's already data here. Do you want to replace it?".into()));
|
||||
}
|
||||
// On the range's sheet, which `paste` would leave to the active one. The selection
|
||||
// belongs to the active sheet: it follows the moved cells only when they are on it.
|
||||
let selection = s.doc()?.selection.clone();
|
||||
let pasted = paste_special(s, &json!({"what": "all", "at": to.a1(), "sheet": sheet}))?;
|
||||
if sheet != s.doc()?.wb.active_sheet() {
|
||||
s.doc_mut()?.selection = selection;
|
||||
}
|
||||
Ok(pasted)
|
||||
});
|
||||
if moved.is_err() {
|
||||
s.clipboard = kept;
|
||||
}
|
||||
moved
|
||||
}
|
||||
|
||||
fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start);
|
||||
@@ -226,16 +261,55 @@ pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
|
||||
let mut row = Vec::new();
|
||||
let mut field = String::new();
|
||||
let mut chars = text.chars().peekable();
|
||||
// Text read again as unquoted, ahead of the rest of the input: a field that opened with a
|
||||
// quote but turned out not to be quoted.
|
||||
let mut replay: std::collections::VecDeque<char> = std::collections::VecDeque::new();
|
||||
// Text is replayed at most as much as the input is long; past that, quotes are text, so
|
||||
// hostile input full of fields that only look quoted reads in linear time.
|
||||
let mut replay_budget = text.len();
|
||||
let mut quoted = false;
|
||||
let mut at_start = true;
|
||||
while let Some(c) = chars.next() {
|
||||
// A quoted field's text as it stands in the input, for a field that turns out not to be one.
|
||||
let mut raw = String::new();
|
||||
loop {
|
||||
let c = match replay.pop_front().or_else(|| chars.next()) {
|
||||
Some(c) => c,
|
||||
// A quote that never closes: the field is its text as written.
|
||||
None if quoted => {
|
||||
quoted = false;
|
||||
field.clear();
|
||||
field.push('"');
|
||||
replay_budget = replay_budget.saturating_sub(raw.len());
|
||||
replay.extend(raw.chars().skip(1));
|
||||
raw.clear();
|
||||
continue;
|
||||
}
|
||||
None => break,
|
||||
};
|
||||
if quoted {
|
||||
raw.push(c);
|
||||
if c == '"' {
|
||||
if chars.peek() == Some(&'"') {
|
||||
let next = replay.front().copied().or_else(|| chars.peek().copied());
|
||||
if next == Some('"') {
|
||||
field.push('"');
|
||||
chars.next();
|
||||
raw.push('"');
|
||||
if replay.pop_front().is_none() {
|
||||
chars.next();
|
||||
}
|
||||
} else {
|
||||
quoted = false;
|
||||
// A field is quoted only when its closing quote ends it, as Excel reads it:
|
||||
// `"hi" there` is that text, quotes included, and a tab or line break inside
|
||||
// it still ends the cell, as if the leading quote weren't there.
|
||||
if !matches!(next, None | Some('\t' | '\n' | '\r')) {
|
||||
field.clear();
|
||||
field.push('"');
|
||||
replay_budget = replay_budget.saturating_sub(raw.len());
|
||||
let rest: Vec<char> = std::mem::take(&mut raw).chars().skip(1).collect();
|
||||
for ch in rest.into_iter().rev() {
|
||||
replay.push_front(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
field.push(c);
|
||||
@@ -243,9 +317,10 @@ pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'"' if at_start => {
|
||||
'"' if at_start && replay_budget > 0 => {
|
||||
quoted = true;
|
||||
at_start = false;
|
||||
raw = String::from('"');
|
||||
}
|
||||
'\t' => {
|
||||
row.push(std::mem::take(&mut field));
|
||||
|
||||
@@ -62,6 +62,8 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let by_cols = bool_param(p, "byColumns").unwrap_or(false);
|
||||
let all = bool_param(p, "all").unwrap_or(false);
|
||||
let workbook = str_param(p, "within") == Some("workbook");
|
||||
// `selection`: only the selected cells of the active sheet, as Excel searches a selected range.
|
||||
let in_selection = str_param(p, "within") == Some("selection");
|
||||
s.last_find = Some(p.clone());
|
||||
let d = s.doc()?;
|
||||
let sheets: Vec<usize> = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet()] };
|
||||
@@ -71,6 +73,7 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let mut cells: Vec<(CellRef, String)> = sh
|
||||
.cells
|
||||
.iter()
|
||||
.filter(|(c, _)| !in_selection || d.selection.contains(*c))
|
||||
.filter_map(|(c, cell)| {
|
||||
let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) {
|
||||
crate::display::cell_text(&d.wb, sh, c)
|
||||
@@ -100,7 +103,12 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) };
|
||||
let dd = s.doc_mut()?;
|
||||
dd.wb_switch_sheet(si);
|
||||
dd.selection = crate::Selection::at(c);
|
||||
if in_selection {
|
||||
// The range stays selected and the active cell moves through it, as in Excel.
|
||||
dd.selection.active = c;
|
||||
} else {
|
||||
dd.selection = crate::Selection::at(c);
|
||||
}
|
||||
Ok(json!({"cell": c.a1(), "sheet": si, "value": t, "count": hits.len()}))
|
||||
}
|
||||
|
||||
@@ -114,6 +122,7 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let whole = bool_param(p, "wholeCell").unwrap_or(false);
|
||||
let all = bool_param(p, "all").unwrap_or(true);
|
||||
let workbook = str_param(p, "within") == Some("workbook");
|
||||
let selection = (str_param(p, "within") == Some("selection")).then(|| s.doc().map(|d| d.selection.clone())).transpose()?;
|
||||
let active = s.doc()?.selection.active;
|
||||
let mut skipped = 0usize;
|
||||
let n = edit(s, |cx| {
|
||||
@@ -123,7 +132,7 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let cells: Vec<(CellRef, String)> =
|
||||
cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default();
|
||||
for (c, text) in cells {
|
||||
if !all && c != active {
|
||||
if (!all && c != active) || selection.as_ref().is_some_and(|sel| !sel.contains(c)) {
|
||||
continue;
|
||||
}
|
||||
if !matches(&text, &what, case, whole) {
|
||||
|
||||
@@ -284,7 +284,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
crate::cmd::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]);
|
||||
if let Some(mut dv) = rule {
|
||||
// Relative references move with the rule, as pasted formulas do.
|
||||
let first = dv.ranges.first().map_or(sc, |r| r.start);
|
||||
let first = RangeRef::top_left(&dv.ranges).unwrap_or(sc);
|
||||
dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest);
|
||||
dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest));
|
||||
dv.ranges = vec![RangeRef::new(dest, dest)];
|
||||
|
||||
@@ -137,6 +137,23 @@ pub(super) fn selection_next(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"active": d.selection.active.a1()}))
|
||||
}
|
||||
|
||||
pub(super) fn selection_next_corner(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let d = s.doc_mut()?;
|
||||
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
|
||||
// A corner in a merged cell lands on its top-left cell. One in a hidden row or column, one
|
||||
// the sheet's protection doesn't let you select, and a merged cell that starts outside the
|
||||
// selected area are skipped.
|
||||
let area = d.selection.current();
|
||||
let stop = |c: CellRef| {
|
||||
Some(sheet.merge_at(c).map_or(c, |m| m.start))
|
||||
.filter(|c| area.contains(*c) && !sheet.is_row_hidden(c.row) && !sheet.is_col_hidden(c.col) && cell_selectable(&d.wb, sheet, *c))
|
||||
};
|
||||
let mut next = d.selection.clone();
|
||||
next.next_corner_by(stop);
|
||||
d.selection = next;
|
||||
Ok(json!({"active": d.selection.active.a1(), "selection": d.selection.a1()}))
|
||||
}
|
||||
|
||||
pub(super) fn select_all(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let d = s.doc_mut()?;
|
||||
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
|
||||
|
||||
@@ -307,6 +307,9 @@ fn tsv() {
|
||||
assert_eq!(parse_tsv("a\tb\n1\t2\n"), vec![vec!["a", "b"], vec!["1", "2"]]);
|
||||
assert_eq!(parse_tsv("\"x\ty\"\tz"), vec![vec!["x\ty", "z"]]);
|
||||
assert_eq!(parse_tsv("\"he said \"\"hi\"\"\""), vec![vec!["he said \"hi\""]]);
|
||||
// #2225: a quote that doesn't close the field leaves it as written, as Excel pastes it.
|
||||
assert_eq!(parse_tsv("\"hi\" there\tx\n"), vec![vec!["\"hi\" there", "x"]]);
|
||||
assert_eq!(parse_tsv("say \"hi\"\n"), vec![vec!["say \"hi\""]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -353,6 +356,74 @@ fn cut_paste_that_overflows_the_grid_moves_nothing() {
|
||||
assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_and_drop_asks_before_replacing_data() {
|
||||
// #3136: dragging column B's border onto column A replaced A without asking.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1:B3", "values": [["Apple", "Kiwi"], ["Pear", "Plum"], ["Fig", "Lime"]]})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "D9"})).unwrap();
|
||||
let text = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["input"].clone();
|
||||
let r = s.execute("range.move", json!({"range": "B:B", "to": "A1"}));
|
||||
let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") };
|
||||
assert_eq!(q, "There's already data here. Do you want to replace it?");
|
||||
// Nothing moved, and the copy made before is still on the clipboard.
|
||||
assert_eq!(text(&mut s, "A1"), json!("Apple"));
|
||||
assert_eq!(text(&mut s, "B1"), json!("Kiwi"));
|
||||
assert!(s.clipboard.as_ref().is_some_and(|c| !c.cut && c.range.a1() == "D9"));
|
||||
// Confirmed, column B replaces column A.
|
||||
s.execute("range.move", json!({"range": "B:B", "to": "A1", "overwrite": true})).unwrap();
|
||||
assert_eq!(text(&mut s, "A1"), json!("Kiwi"));
|
||||
assert_eq!(text(&mut s, "A3"), json!("Lime"));
|
||||
assert_eq!(text(&mut s, "B1"), json!(""));
|
||||
// Onto empty cells, or overlapping only the moved cells themselves, nothing is asked.
|
||||
s.execute("range.move", json!({"range": "A1:A3", "to": "A2"})).unwrap();
|
||||
assert_eq!(text(&mut s, "A4"), json!("Lime"));
|
||||
s.execute("range.move", json!({"range": "A2:A4", "to": "C2"})).unwrap();
|
||||
assert_eq!(text(&mut s, "C2"), json!("Kiwi"));
|
||||
assert!(s.execute("range.move", json!({"range": "C2"})).is_err(), "`to` is required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_and_drop_asks_before_replacing_spilled_values() {
|
||||
// Cells showing a dynamic array's spilled values hold no cell of their own, but they are data.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "C2:C3", "values": [["Kiwi"], ["Plum"]]})).unwrap();
|
||||
let r = s.execute("range.move", json!({"range": "C2:C3", "to": "A2"}));
|
||||
let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") };
|
||||
assert_eq!(q, "There's already data here. Do you want to replace it?");
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.value(CellRef::new(2, 2)), Value::text("Plum"));
|
||||
s.execute("range.move", json!({"range": "C2:C3", "to": "A2", "overwrite": true})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.value(CellRef::new(1, 0)), Value::text("Kiwi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_and_drop_on_another_sheet_stays_on_that_sheet() {
|
||||
// The move was checked on the range's sheet but pasted on the active one, replacing its data
|
||||
// without asking.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.insertSheet", json!({"name": "Two"})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "B1:B2", "values": [["Kiwi"], ["Plum"]]})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "Keep"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "C5"})).unwrap();
|
||||
s.execute("range.move", json!({"range": "Two!B1:B2", "to": "A1"})).unwrap();
|
||||
// Sheet1 stays active with its own selection; the moved cells are on Two.
|
||||
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
|
||||
assert_eq!(s.doc().unwrap().selection.a1(), "C5");
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let two = wb.sheet_index("Two").unwrap();
|
||||
assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(0, 0)), Value::text("Keep"));
|
||||
assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(0, 0)), Value::text("Kiwi"));
|
||||
assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(1, 0)), Value::text("Plum"));
|
||||
assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(0, 1)), Value::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_case_insensitive() {
|
||||
assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear");
|
||||
@@ -371,3 +442,141 @@ fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() {
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_corner_of_selection_goes_clockwise_and_anchors_there() {
|
||||
// #1442, as Excel does it: B2 → D2 → D4 → B4 → B2, the area stays selected, and Shift+arrows
|
||||
// then move the corner opposite the new active cell.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("selection.set", json!({"range": "B2:D4", "active": "B2"})).unwrap();
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..4 {
|
||||
let r = s.execute("selection.nextCorner", json!({})).unwrap();
|
||||
assert_eq!(r["selection"], "B2:D4");
|
||||
seen.push(r["active"].as_str().unwrap().to_string());
|
||||
}
|
||||
assert_eq!(seen, ["D2", "D4", "B4", "B2"]);
|
||||
s.execute("selection.nextCorner", json!({})).unwrap();
|
||||
s.execute("selection.nextCorner", json!({})).unwrap();
|
||||
s.execute("selection.move", json!({"dr": 0, "dc": 1, "extend": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.current().a1(), "C2:D4");
|
||||
// One row goes end to end; a single cell stays.
|
||||
s.execute("selection.set", json!({"range": "B2:D2", "active": "B2"})).unwrap();
|
||||
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "D2");
|
||||
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B2");
|
||||
s.execute("selection.set", json!({"range": "C5"})).unwrap();
|
||||
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_corner_skips_corners_the_sheet_protection_does_not_let_you_select() {
|
||||
// With locked cells unselectable, Ctrl+. goes on to the next corner it may select.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.lockCell", json!({"range": "B2:C3", "on": false})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap();
|
||||
s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap();
|
||||
let r = s.execute("selection.nextCorner", json!({})).unwrap();
|
||||
assert_eq!((r["active"].as_str(), r["selection"].as_str()), (Some("B3"), Some("B2:D3")), "D2 and D3 are locked");
|
||||
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_corner_stops_once_on_a_merged_corner_at_its_top_left_cell() {
|
||||
// A corner inside a merged cell is that merged cell, whose active cell is its top-left one.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.mergeCells", json!({"range": "C2:D2"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap();
|
||||
let seen: Vec<String> = (0..4).map(|_| s.execute("selection.nextCorner", json!({})).unwrap()["active"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(seen, ["C2", "D3", "B3", "B2"]);
|
||||
// A selection that is one merged cell has no other corner.
|
||||
s.execute("selection.set", json!({"range": "C2:D2", "active": "C2"})).unwrap();
|
||||
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_corner_skips_hidden_corners_and_stays_inside_the_selected_area() {
|
||||
// A corner in a hidden column is passed over, as keyboard moves pass over hidden cells.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.hideColumns", json!({"cols": "D:D"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap();
|
||||
assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B3");
|
||||
// Column C selected with B1:C1 merged: its top corner's merged cell starts in column B,
|
||||
// outside the selection, so the active cell doesn't go there.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.mergeCells", json!({"range": "B1:C1"})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "C5"})).unwrap();
|
||||
s.execute("selection.column", json!({})).unwrap();
|
||||
let r = s.execute("selection.nextCorner", json!({})).unwrap();
|
||||
assert_eq!((r["active"].as_str(), r["selection"].as_str()), (Some("C1048576"), Some("C:C")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_quotes_only_cells_with_tabs_or_line_breaks() {
|
||||
// #2225: a cell holding a quote was wrapped in quotes on copy; Excel quotes only text with a
|
||||
// tab or line break, and pastes the result back as the same cells.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
for (cell, input) in [("A1", "say \"hi\""), ("A2", "plain"), ("A3", "\"hi\" there"), ("A4", "x \"q\"\ny"), ("A5", "tab\tinside")] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
}
|
||||
let text = s.execute("edit.copy", json!({"range": "A1:A5"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert_eq!(text, "say \"hi\"\nplain\n\"hi\" there\n\"x \"\"q\"\"\ny\"\n\"tab\tinside\"\n");
|
||||
let rows = parse_tsv(&text);
|
||||
let cells: Vec<&str> = rows.iter().map(|r| r[0].as_str()).collect();
|
||||
assert_eq!(cells, ["say \"hi\"", "plain", "\"hi\" there", "x \"q\"\ny", "tab\tinside"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_that_is_not_really_quoted_keeps_its_separators() {
|
||||
// A field opening with a quote is quoted only if a closing quote ends it; when it doesn't (or
|
||||
// never closes), the text is read as written, and the tabs and line breaks inside it still
|
||||
// separate cells, as they would have without the leading quote.
|
||||
assert_eq!(parse_tsv("\"a\tb\"x\tc"), vec![vec!["\"a", "b\"x", "c"]]);
|
||||
assert_eq!(parse_tsv("\"a\tb\nc"), vec![vec!["\"a", "b"], vec!["c"]]);
|
||||
assert_eq!(parse_tsv("\"a\tb\tc\n"), vec![vec!["\"a", "b", "c"]]);
|
||||
// Cells copied without quotes (they hold no tab or line break) read back as the same cells.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
for (cell, input) in [("A1", "'\"a"), ("B1", "b\"x"), ("C1", "c")] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
}
|
||||
let text = s.execute("edit.copy", json!({"range": "A1:C1"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert_eq!(parse_tsv(&text), vec![vec!["\"a", "b\"x", "c"]], "{text:?}");
|
||||
// Many fields that only look quoted: each is replayed at most once (the replay budget is the
|
||||
// input's length), and every one is still its own cell.
|
||||
let hostile = "\"x\t".repeat(50_000);
|
||||
assert_eq!(parse_tsv(&hostile).first().map(Vec::len), Some(50_001));
|
||||
}
|
||||
|
||||
/// `within: selection` searches only the selected cells, as Excel does with a range selected
|
||||
/// (#3224): Find All lists them, Find Next moves the active cell through them and keeps the range
|
||||
/// selected, and Replace changes only them.
|
||||
#[test]
|
||||
fn find_and_replace_within_the_selection() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1:B4", "values": [["pear", "pear"], ["plum", "pear"], ["pear", "fig"], ["pear", "pear"]]}))
|
||||
.unwrap();
|
||||
s.execute("selection.set", json!({"range": "B1:B4"})).unwrap();
|
||||
let all = s.execute("edit.find", json!({"what": "pear", "all": true, "within": "selection"})).unwrap();
|
||||
let cells: Vec<&str> = all["results"].as_array().unwrap().iter().filter_map(|r| r["cell"].as_str()).collect();
|
||||
assert_eq!(cells, ["B1", "B2", "B4"], "only column B");
|
||||
assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B2");
|
||||
assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B4");
|
||||
assert_eq!(s.execute("edit.find", json!({"what": "pear", "within": "selection"})).unwrap()["cell"], "B1", "wraps inside the range");
|
||||
let sel = &s.doc().unwrap().selection;
|
||||
assert_eq!((sel.a1(), sel.active.a1()), ("B1:B4".to_string(), "B1".to_string()), "the range stays selected");
|
||||
assert!(s.execute("edit.find", json!({"what": "plum", "within": "selection"})).is_err(), "A2 is outside the range");
|
||||
assert_eq!(s.execute("edit.replace", json!({"what": "pear", "with": "kiwi", "within": "selection"})).unwrap()["replaced"], 3);
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
let at = |a: &str| sh.value(CellRef::parse(a).unwrap());
|
||||
assert_eq!((at("A1"), at("B1"), at("B4"), at("A4")), (Value::text("pear"), Value::text("kiwi"), Value::text("kiwi"), Value::text("pear")));
|
||||
// The sheet scope is unchanged.
|
||||
let all = s.execute("edit.find", json!({"what": "pear", "all": true})).unwrap();
|
||||
assert_eq!(all["count"], 3, "A1, A3 and A4 on the whole sheet");
|
||||
}
|
||||
|
||||
@@ -1733,7 +1733,13 @@ fn clear_cf(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
sh.cond_formats.clear();
|
||||
} else {
|
||||
for cf in sh.cond_formats.iter_mut() {
|
||||
let before = RangeRef::top_left(&cf.ranges);
|
||||
cf.ranges.retain(|r| !ranges.iter().any(|x| x.intersects(r)));
|
||||
// The relative formulas are written for the top-left cell: when it goes, they're
|
||||
// rewritten for the new one, so the areas left keep reading the same cells.
|
||||
if let (Some(from), Some(to)) = (before, RangeRef::top_left(&cf.ranges)) {
|
||||
rebase_cf_formulas(&mut cf.rule, from, to);
|
||||
}
|
||||
}
|
||||
sh.cond_formats.retain(|c| !c.ranges.is_empty());
|
||||
}
|
||||
@@ -1741,6 +1747,19 @@ fn clear_cf(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Moves the relative references of a rule's per-cell formulas written for `from` to `to`.
|
||||
fn rebase_cf_formulas(rule: &mut CfRule, from: CellRef, to: CellRef) {
|
||||
let mv = |f: &str| crate::cmd::data::move_rule_formula(f, from, to);
|
||||
match rule {
|
||||
CfRule::CellIs { a, b, .. } => {
|
||||
*a = mv(a);
|
||||
*b = b.as_deref().map(mv);
|
||||
}
|
||||
CfRule::Expression { formula, .. } => *formula = mv(formula),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn manage_cf(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
if p.get("delete").is_none() && p.get("moveUp").is_none() && p.get("moveDown").is_none() {
|
||||
|
||||
@@ -507,9 +507,13 @@ pub(super) fn value_settings(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let mut v = cur.clone();
|
||||
if let Some(f) = func {
|
||||
v.func = f;
|
||||
// An automatic caption follows the function.
|
||||
let auto = auto_name(cur.func, &cur.source_col);
|
||||
if cur.name.eq_ignore_ascii_case(&auto) || cur.name.strip_prefix(auto.as_str()).is_some_and(|r| r.chars().all(|c| c.is_ascii_digit())) {
|
||||
// An automatic caption follows the function. Count Numbers was captioned "Count
|
||||
// Numbers of …" before it took Excel's "Count of …", and saved workbooks still carry it.
|
||||
let is_auto = |base: &str| {
|
||||
cur.name.eq_ignore_ascii_case(base) || cur.name.strip_prefix(base).is_some_and(|r| r.chars().all(|c| c.is_ascii_digit()))
|
||||
};
|
||||
let legacy = cur.func == PivotFunc::CountNumbers && is_auto(&format!("Count Numbers of {}", cur.source_col));
|
||||
if is_auto(&auto_name(cur.func, &cur.source_col)) || legacy {
|
||||
v.name = unique_value_name(pt, &auto_name(f, &cur.source_col), Some(i));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,19 @@ fn filters_and_report_filter_rows() {
|
||||
assert!(s.execute("pivot.filter", json!({"field": "Product", "selected": []})).is_err());
|
||||
}
|
||||
|
||||
/// A selection of the most labels allowed (100,000) is looked up once per record, not scanned: the
|
||||
/// report still filters case-insensitively.
|
||||
#[test]
|
||||
fn a_huge_filter_selection_is_matched_through_a_set() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap();
|
||||
let mut selected: Vec<String> = (0..99_999).map(|i| format!("Region {i}")).collect();
|
||||
selected.push("EAST".into());
|
||||
s.execute("pivot.filter", json!({"field": "Region", "selected": selected})).unwrap();
|
||||
let gt = find_row(&s, "Grand Total").unwrap();
|
||||
assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(415.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_values_as() {
|
||||
let mut s = sales();
|
||||
@@ -1013,3 +1026,36 @@ fn detail_cells_are_the_cells_show_details_takes() {
|
||||
let pt = &d.wb.active().unwrap().pivots[0];
|
||||
assert!(crate::pivot::detail_cells(&d.wb, pt, d.wb.active_sheet()).unwrap_err().contains("out of date"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_ties_are_dense_and_count_numbers_is_captioned_count_of() {
|
||||
// #1448 items 10 and 12: Excel ranks 5, 4, 3, 3, 4 as 1, 2, 3, 3, 2 (GridCraft gave 1, 2, 4,
|
||||
// 4, 2), and captions a Count Numbers field "Count of Val".
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let rows = json!([["Key", "Val"], ["A", 5], ["B", 4], ["C", 3], ["D", 3], ["E", 4]]);
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
|
||||
s.execute("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$6", "destination": "Sheet1!D3", "rows": ["Key"], "values": ["Val"]})).unwrap();
|
||||
s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "rank"})).unwrap();
|
||||
let ranks: Vec<Value> = ["E4", "E5", "E6", "E7", "E8"].iter().map(|a| val(&s, a)).collect();
|
||||
assert_eq!(ranks, [1.0, 2.0, 3.0, 3.0, 2.0].map(Value::Number));
|
||||
s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "normal", "func": "countNumbers"})).unwrap();
|
||||
assert_eq!(text(&s, "E3"), "Count of Val");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_count_numbers_caption_follows_the_function() {
|
||||
// Workbooks saved before Count Numbers was captioned "Count of" carry "Count Numbers of Val";
|
||||
// that is still an automatic caption, so it follows a new function. A custom name stays.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let rows = json!([["Key", "Val"], ["A", 5], ["B", 4]]);
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
|
||||
s.execute("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$3", "destination": "Sheet1!D3", "rows": ["Key"], "values": ["Val"]})).unwrap();
|
||||
for (legacy, after) in [("Count Numbers of Val", "Sum of Val"), ("Count Numbers of Val2", "Sum of Val"), ("Numbers counted", "Numbers counted")] {
|
||||
s.execute("pivot.valueSettings", json!({"index": 0, "func": "countNumbers", "name": legacy})).unwrap();
|
||||
assert_eq!(text(&s, "E3"), legacy);
|
||||
s.execute("pivot.valueSettings", json!({"index": 0, "func": "sum"})).unwrap();
|
||||
assert_eq!(text(&s, "E3"), after, "{legacy}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -695,6 +695,79 @@ fn bands_unit() {
|
||||
assert!(w.len() > 2);
|
||||
}
|
||||
|
||||
/// The wrapper `wrap_lines` replaced, re-measuring strings, as the reference for its lines.
|
||||
fn wrap_lines_remeasuring(text: &str, font: Font, size: f32, width: f32) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for para in text.split('\n') {
|
||||
let mut line = String::new();
|
||||
for word in para.split(' ') {
|
||||
let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
|
||||
if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width {
|
||||
line = cand;
|
||||
} else {
|
||||
out.push(std::mem::take(&mut line));
|
||||
line = word.to_string();
|
||||
}
|
||||
while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let (mut lo, mut hi) = (1usize, chars.len());
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo).div_ceil(2);
|
||||
let cut: String = chars[..mid].iter().collect();
|
||||
if gridcraft_pdf::text_width(font, size, &cut) <= width {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
if lo == chars.len() {
|
||||
break;
|
||||
}
|
||||
out.push(chars[..lo].iter().collect());
|
||||
line = chars[lo..].iter().collect();
|
||||
}
|
||||
if out.len() > 2000 {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_lines_matches_the_remeasuring_wrapper() {
|
||||
let texts = [
|
||||
"the quick brown fox jumps",
|
||||
"Supercalifragilistic",
|
||||
"a b c\n\nverylongwordthatneedsbreaking short\nend ",
|
||||
"Wide WWWWWWWWWW words, iiiiiiii narrow ones; mixed MMMMiiiiMMMM",
|
||||
"€ ü ß — unicode Ünïcödé text",
|
||||
"",
|
||||
" ",
|
||||
];
|
||||
for text in texts {
|
||||
for font in [Font::Helvetica, Font::HelveticaBold, Font::Courier] {
|
||||
for (size, width) in [(10.0, 50.0), (10.0, 20.0), (11.0, 1.0), (8.0, 300.0), (12.0, 0.0)] {
|
||||
assert_eq!(wrap_lines(text, font, size, width), wrap_lines_remeasuring(text, font, size, width), "{text:?} {font:?} {size} {width}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapping_a_long_word_in_a_narrow_box_stops_at_the_line_limit() {
|
||||
// One character per line: the old wrapper re-measured and copied the rest of the word for
|
||||
// each of the 100,000 lines before checking the limit.
|
||||
let word = "w".repeat(100_000);
|
||||
let lines = wrap_lines(&word, Font::Helvetica, 10.0, 1.0);
|
||||
assert!(lines.len() <= 2002, "{}", lines.len());
|
||||
assert!(lines.iter().all(|l| l == "w"));
|
||||
// A long paragraph in a very wide box is one line.
|
||||
let para = vec!["word"; 50_000].join(" ");
|
||||
assert_eq!(wrap_lines(¶, Font::Helvetica, 10.0, 1.0e9), vec![para.clone()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_across_spans_following_empty_center_across_cells() {
|
||||
let mut styles = StyleTable::default();
|
||||
|
||||
@@ -3,42 +3,57 @@ use super::{CellRef, Color, Font, HAlign, MAX_COLS, Page, Point, RangeRef, Sheet
|
||||
|
||||
const PAD_PX: f32 = 2.0;
|
||||
|
||||
/// Most lines [`wrap_lines`] returns (it may return one more, the rest of a paragraph).
|
||||
const MAX_WRAP_LINES: usize = 2000;
|
||||
|
||||
/// Word-wraps `text` to `width` points.
|
||||
///
|
||||
/// Widths are kept as running sums of glyph widths (the sum `text_width` makes), so each
|
||||
/// character is measured once: re-measuring the line for every word, or the rest of an
|
||||
/// over-long word for every piece cut from it, made a long paragraph or word quadratic.
|
||||
pub(super) fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec<String> {
|
||||
let units_of = |s: &str| -> u64 { s.chars().map(|c| u64::from(font.char_width(c))).sum() };
|
||||
let fits = |units: u64| units as f32 * size / 1000.0 <= width;
|
||||
let space = units_of(" ");
|
||||
let mut out = Vec::new();
|
||||
for para in text.split('\n') {
|
||||
let mut line = String::new();
|
||||
let mut line_units: u64 = 0;
|
||||
for word in para.split(' ') {
|
||||
let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
|
||||
if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width {
|
||||
line = cand;
|
||||
let word_units = units_of(word);
|
||||
if line.is_empty() {
|
||||
line.push_str(word);
|
||||
line_units = word_units;
|
||||
} else if fits(line_units + space + word_units) {
|
||||
line.push(' ');
|
||||
line.push_str(word);
|
||||
line_units += space + word_units;
|
||||
} else {
|
||||
out.push(std::mem::take(&mut line));
|
||||
line = word.to_string();
|
||||
line.push_str(word);
|
||||
line_units = word_units;
|
||||
}
|
||||
// Break a single over-long word by characters. The cut that fits is found by binary
|
||||
// search (each try measures one candidate prefix), so an over-long word costs
|
||||
// O(n log n) measurements instead of re-measuring the whole cut per character for
|
||||
// every emitted line (`a` × 100k pasted text would be cubic worst case).
|
||||
while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let (mut lo, mut hi) = (1usize, chars.len());
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo).div_ceil(2);
|
||||
let cut: String = chars[..mid].iter().collect();
|
||||
if gridcraft_pdf::text_width(font, size, &cut) <= width {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
// Break a single over-long word by characters: each piece is the longest that fits
|
||||
// (at least one character).
|
||||
if !fits(line_units) && line.chars().nth(1).is_some() {
|
||||
let mut piece = String::new();
|
||||
let mut units: u64 = 0;
|
||||
for c in line.chars() {
|
||||
let w = u64::from(font.char_width(c));
|
||||
if !piece.is_empty() && !fits(units + w) {
|
||||
out.push(std::mem::take(&mut piece));
|
||||
units = 0;
|
||||
if out.len() > MAX_WRAP_LINES {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
piece.push(c);
|
||||
units += w;
|
||||
}
|
||||
if lo == chars.len() {
|
||||
break;
|
||||
}
|
||||
out.push(chars[..lo].iter().collect());
|
||||
line = chars[lo..].iter().collect();
|
||||
line = piece;
|
||||
line_units = units;
|
||||
}
|
||||
if out.len() > 2000 {
|
||||
if out.len() > MAX_WRAP_LINES {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,10 +398,10 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
{
|
||||
return Err(EngineError::Other("To prevent possible loss of data, columns cannot be shifted off of the worksheet.".into()));
|
||||
}
|
||||
// The checks must authorize the sheet this command modifies (`target_sheet`), not whatever
|
||||
// sheet is active: both carry the command's own `sheet` parameter on.
|
||||
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1(), "sheet": p.get("sheet")}), Axis::Cols, before, count, true)?;
|
||||
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1(), "sheet": p.get("sheet")}), Axis::Cols, from, count, false)?;
|
||||
// The checks must authorize the sheet this command modifies, not whatever sheet is active:
|
||||
// both get the index `target_sheet` resolved (from `sheet`, or a `Sheet!` range or cell).
|
||||
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1(), "sheet": sheet}), Axis::Cols, before, count, true)?;
|
||||
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1(), "sheet": sheet}), Axis::Cols, from, count, false)?;
|
||||
|
||||
let moved_from = if before <= from { from + count } else { from };
|
||||
if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) {
|
||||
|
||||
@@ -40,6 +40,27 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")),
|
||||
cmd!("view.showOutlineSymbols", "Show Outline Symbols", [], Some("Ctrl+8"), "{on?}", has_doc, |s, p| sheet_flag(s, p, "outlineSymbols")),
|
||||
cmd!("view.showObjects", "Show Objects", [], Some("Ctrl+6"), "{on?}", has_doc, show_objects),
|
||||
cmd!(
|
||||
"view.showSheetTabs",
|
||||
"Show Sheet Tabs",
|
||||
[],
|
||||
None,
|
||||
"{on?} (File › Options › Advanced, this workbook; toggles without `on`)",
|
||||
has_doc,
|
||||
|s, p| { workbook_view_flag(s, p, "sheetTabs") }
|
||||
),
|
||||
cmd!(
|
||||
"view.showHorizontalScrollBar",
|
||||
"Show Horizontal Scroll Bar",
|
||||
[],
|
||||
None,
|
||||
"{on?} (this workbook; toggles without `on`)",
|
||||
has_doc,
|
||||
|s, p| { workbook_view_flag(s, p, "horizontal") }
|
||||
),
|
||||
cmd!("view.showVerticalScrollBar", "Show Vertical Scroll Bar", [], None, "{on?} (this workbook; toggles without `on`)", has_doc, |s, p| {
|
||||
workbook_view_flag(s, p, "vertical")
|
||||
}),
|
||||
cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")),
|
||||
cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom),
|
||||
cmd!("view.zoom100", "100%", ["View", "Zoom"], None, "{}", has_doc, |s, _| zoom(s, &json!({"percent": 100}))),
|
||||
@@ -189,6 +210,21 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// File › Options › Advanced › Display options for this workbook: the sheet tabs and the two
|
||||
/// scroll bars, saved with the workbook (`<workbookView showSheetTabs … />`).
|
||||
fn workbook_view_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
|
||||
edit(s, |cx| {
|
||||
let view = &mut cx.wb.view;
|
||||
let slot = match which {
|
||||
"sheetTabs" => &mut view.show_sheet_tabs,
|
||||
"horizontal" => &mut view.show_horizontal_scroll,
|
||||
_ => &mut view.show_vertical_scroll,
|
||||
};
|
||||
*slot = bool_param(p, "on").unwrap_or(!*slot);
|
||||
Ok(json!({"on": *slot}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Ctrl+6: hides every object, or shows them again. Placeholders (from a file) count as shown.
|
||||
fn show_objects(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
use gridcraft_model::ShowObjects;
|
||||
|
||||
@@ -156,7 +156,9 @@ pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String {
|
||||
}
|
||||
first = false;
|
||||
let t = cell_text(wb, sh, CellRef::new(row, col));
|
||||
if t.contains(['\t', '\n', '"']) {
|
||||
// Like Excel, only text with a tab or line break is quoted (its quotes doubled);
|
||||
// `say "hi"` is copied as it is (#2225).
|
||||
if t.contains(['\t', '\n', '\r']) {
|
||||
out.push('"');
|
||||
out.push_str(&t.replace('"', "\"\""));
|
||||
out.push('"');
|
||||
|
||||
+120
-1
@@ -1,4 +1,4 @@
|
||||
//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export.
|
||||
//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, SYLK import, CSV/TSV, JSON (debug), HTML export.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
@@ -7,6 +7,8 @@ use gridcraft_model::{Sheet, Workbook};
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
mod sylk;
|
||||
|
||||
/// File formats GridCraft reads or writes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FileKind {
|
||||
@@ -19,6 +21,8 @@ pub enum FileKind {
|
||||
Tsv,
|
||||
Json,
|
||||
Html,
|
||||
/// SYLK symbolic link text (import only).
|
||||
Sylk,
|
||||
}
|
||||
|
||||
impl FileKind {
|
||||
@@ -33,6 +37,7 @@ impl FileKind {
|
||||
"tsv" | "tab" | "txt" => FileKind::Tsv,
|
||||
"json" | "scjson" => FileKind::Json,
|
||||
"html" | "htm" => FileKind::Html,
|
||||
"slk" => FileKind::Sylk,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -291,6 +296,9 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
|
||||
})?;
|
||||
return Ok((wb, report.warnings));
|
||||
}
|
||||
if let Some(read) = read_html_table(name, kind, bytes, csv_as_text) {
|
||||
return read;
|
||||
}
|
||||
match kind {
|
||||
Some(FileKind::Json) => {
|
||||
let mut wb: Workbook = serde_json::from_slice(bytes).map_err(|e| EngineError::Other(format!("not a GridCraft JSON workbook: {e}")))?;
|
||||
@@ -310,6 +318,11 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
|
||||
}
|
||||
Ok((wb, warnings))
|
||||
}
|
||||
Some(FileKind::Sylk) => {
|
||||
let (mut wb, warnings) = sylk::read(name, bytes)?;
|
||||
rename_first_sheet(&mut wb, name);
|
||||
Ok((wb, warnings))
|
||||
}
|
||||
Some(FileKind::Tsv) => {
|
||||
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() };
|
||||
let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
@@ -346,9 +359,112 @@ fn part_too_large_message(name: &str, part: &str, size: u64) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// How much of a text file is read to tell whether it is a web page.
|
||||
const HTML_SNIFF_BYTES: usize = 64 << 10;
|
||||
|
||||
/// A web page that holds a table, whatever its extension: a web app's "Export to Excel" is often
|
||||
/// HTML saved as `.xls` (#3166). Excel opens its table as a sheet (after warning that the format
|
||||
/// and extension don't match): the cells read like typed input (or as text with `csv_as_text`),
|
||||
/// header cells (`<th>`) are bold and centred, and the sheet takes the name the page gives it
|
||||
/// (`<x:Name>`) or the file's. `None` when the bytes aren't such a page.
|
||||
fn read_html_table(name: &str, kind: Option<FileKind>, bytes: &[u8], csv_as_text: bool) -> Option<Result<(Workbook, Vec<String>)>> {
|
||||
// Every other text file (CSV, TSV, JSON) passes through here: look at the start only, and decode
|
||||
// the whole file once it is known to be a page within the size a text import takes.
|
||||
if matches!(kind, Some(FileKind::Json)) || bytes.len() > gridcraft_xlsx::MAX_CSV_BYTES {
|
||||
return None;
|
||||
}
|
||||
let start = gridcraft_xlsx::decode_text(bytes.get(..HTML_SNIFF_BYTES).unwrap_or(bytes));
|
||||
// Comments an exporter writes before the page (`<!-- ... -->`) don't make it something else.
|
||||
let mut rest = start.trim_start_matches('\u{feff}').trim_start();
|
||||
while let Some(after) = rest.strip_prefix("<!--") {
|
||||
match after.find("-->").and_then(|end| after.get(end + 3..)) {
|
||||
Some(more) => rest = more.trim_start(),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let head: String = rest.chars().take(16).collect::<String>().to_ascii_lowercase();
|
||||
if !["<!doctype html", "<html", "<table", "<head", "<body", "<meta"].iter().any(|t| head.starts_with(t)) {
|
||||
return None;
|
||||
}
|
||||
// Decoded as a CSV is (BOM, UTF-8, else Windows-1252/1256), so a legacy page keeps its letters.
|
||||
let text = gridcraft_xlsx::decode_text(bytes);
|
||||
let rows = crate::clip_html::table_cells(&text, gridcraft_core::MAX_ROWS as usize, gridcraft_core::MAX_COLS as usize);
|
||||
if rows.iter().all(Vec::is_empty) {
|
||||
return None;
|
||||
}
|
||||
// The cells go through the CSV reader, so numbers, dates and text read exactly as they do there.
|
||||
let mut tsv = String::new();
|
||||
for row in &rows {
|
||||
let fields: Vec<String> = row
|
||||
.iter()
|
||||
.map(|cell| {
|
||||
// The number the page recorded (`x:num`) only when values are read; as text, the
|
||||
// cell keeps the text it shows.
|
||||
let field = match cell.num.filter(|_| !csv_as_text) {
|
||||
Some(n) => gridcraft_core::number_to_text(n),
|
||||
// Whitespace is already collapsed; non-breaking spaces stay, as in Excel.
|
||||
None => cell.text.clone(),
|
||||
};
|
||||
if field.contains(['\t', '\n', '\r', '"']) { format!("\"{}\"", field.replace('"', "\"\"")) } else { field }
|
||||
})
|
||||
.collect();
|
||||
tsv.push_str(&fields.join("\t"));
|
||||
tsv.push('\n');
|
||||
}
|
||||
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() };
|
||||
let read = gridcraft_xlsx::read_csv_with_report(tsv.as_bytes(), &opts).map_err(|e| EngineError::Other(e.to_string()));
|
||||
Some(read.map(|(mut wb, report)| {
|
||||
wb.csv_delimiter = None;
|
||||
// Header cells are bold and centred on top of the style the CSV reader gave them, so a
|
||||
// date in a header keeps its date format.
|
||||
let headers: Vec<CellRef> = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(r, row)| row.iter().enumerate().filter(|(_, cell)| cell.header && !cell.text.trim().is_empty()).map(move |(c, _)| CellRef::new(r as u32, c as u32)))
|
||||
.collect();
|
||||
let mut bold: std::collections::HashMap<gridcraft_model::StyleId, gridcraft_model::StyleId> = std::collections::HashMap::new();
|
||||
for at in headers {
|
||||
let Some(base) = wb.sheet(0).map(|sh| sh.style_id(at)) else { continue };
|
||||
let id = *bold.entry(base).or_insert_with(|| {
|
||||
wb.styles.derive(base, |st| {
|
||||
st.font.bold = true;
|
||||
st.align.h = gridcraft_model::HAlign::Center;
|
||||
})
|
||||
});
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.set_style(at, id);
|
||||
}
|
||||
}
|
||||
let page_name = find_between(&text, "<x:Name>", "</x:Name>").map(|n| crate::clip_html::normalized(n.trim()));
|
||||
match page_name.filter(|n| !n.is_empty()) {
|
||||
Some(sheet) => set_first_sheet_name(&mut wb, &sheet),
|
||||
None => rename_first_sheet(&mut wb, name),
|
||||
}
|
||||
let mut warnings = report.warnings;
|
||||
if !matches!(kind, Some(FileKind::Html)) {
|
||||
warnings.insert(0, format!("'{name}' is a web page (HTML), not a workbook in the format its name says: its table was opened as a sheet. Save as .xlsx to keep your changes."));
|
||||
}
|
||||
(wb, warnings)
|
||||
}))
|
||||
}
|
||||
|
||||
/// The text between the first `open` and the `close` after it, matched case-insensitively.
|
||||
fn find_between<'a>(text: &'a str, open: &str, close: &str) -> Option<&'a str> {
|
||||
let lower = text.to_ascii_lowercase();
|
||||
let start = lower.find(&open.to_ascii_lowercase())?.checked_add(open.len())?;
|
||||
let len = lower.get(start..)?.find(&close.to_ascii_lowercase())?;
|
||||
text.get(start..start.checked_add(len)?)
|
||||
}
|
||||
|
||||
/// A CSV opens as a sheet named after the file (like Excel).
|
||||
fn rename_first_sheet(wb: &mut Workbook, path: &str) {
|
||||
let stem = std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Sheet1");
|
||||
set_first_sheet_name(wb, stem);
|
||||
}
|
||||
|
||||
/// Names the first sheet `name`, without the characters a sheet name can't hold and cut to 31
|
||||
/// characters; an empty or reserved result keeps the default name.
|
||||
fn set_first_sheet_name(wb: &mut Workbook, stem: &str) {
|
||||
let clean: String = stem.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
|
||||
let clean = clean.trim_matches('\'').to_string();
|
||||
// "History" is reserved (see `Workbook::check_sheet_name`): keep the default name instead.
|
||||
@@ -389,6 +505,9 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
|
||||
}
|
||||
FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())),
|
||||
FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())),
|
||||
FileKind::Sylk => {
|
||||
Err(EngineError::Other("SYLK (.slk) is supported for import only. Save as .xlsx or another supported export format.".into()))
|
||||
}
|
||||
// A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`).
|
||||
FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default),
|
||||
FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default),
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
//! SYLK (`.slk`) import: the symbolic link text format older spreadsheets and some legacy systems
|
||||
//! export. One record per line, fields separated by `;` (`;;` is a literal semicolon):
|
||||
//!
|
||||
//! - `ID` starts the file; `E` ends it.
|
||||
//! - `C;Y<row>;X<col>;K<value>;E<formula>` is a cell. `Y` and `X` may be left out to stay on the
|
||||
//! current row or column. `K` is a number, `"text"`, `TRUE`/`FALSE` or an error; `E` is a
|
||||
//! formula in R1C1 notation; `S;R<row>;C<col>` shares the formula of another cell.
|
||||
//! - `P;P<code>` adds a number format to the file's list; `F;P<n>;Y<row>;X<col>` gives a cell the
|
||||
//! n-th format (0-based), and `F;W<first> <last> <width>` sets column widths in characters.
|
||||
//!
|
||||
//! Other records (fonts, options, window settings) are skipped. Formulas are converted to A1 with
|
||||
//! the same relative and absolute references, as Excel does when it opens the file.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, Value, col_to_letters};
|
||||
use gridcraft_model::{Cell, Formula, NumFmt, Sheet, Style, Workbook};
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
/// The largest SYLK file read, as for other text imports: past it the file is refused.
|
||||
const MAX_BYTES: usize = 512 << 20;
|
||||
/// Column-width records kept from one file; each can cover every column.
|
||||
const MAX_WIDTH_RECORDS: usize = 1024;
|
||||
|
||||
/// Reads a SYLK file into a one-sheet workbook. Returns the workbook and its warnings.
|
||||
pub fn read(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
if bytes.len() > MAX_BYTES {
|
||||
return Err(EngineError::Other(format!("We can't open '{name}': the SYLK file is larger than {} MB.", MAX_BYTES >> 20)));
|
||||
}
|
||||
let text = match std::str::from_utf8(bytes) {
|
||||
Ok(s) => s.to_string(),
|
||||
// Older exports are in a single-byte code page: keep each byte as its Latin-1 character.
|
||||
Err(_) => bytes.iter().map(|&b| char::from(b)).collect(),
|
||||
};
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
|
||||
if !text.starts_with("ID") {
|
||||
return Err(EngineError::Other(format!("We can't open '{name}': it isn't a SYLK file (it doesn't start with an ID record).")));
|
||||
}
|
||||
let mut sheet = Sheet::new("Sheet1");
|
||||
let mut formats: Vec<String> = Vec::new();
|
||||
// Cells' number formats by index into `formats`, applied once every format is known.
|
||||
let mut cell_formats: Vec<(CellRef, usize)> = Vec::new();
|
||||
let mut formulas: HashMap<CellRef, String> = HashMap::new();
|
||||
let mut widths: Vec<(u32, u32, f64)> = Vec::new();
|
||||
let (mut row, mut col) = (0u32, 0u32);
|
||||
let mut skipped = 0usize;
|
||||
for line in text.lines() {
|
||||
let fields = split_fields(line);
|
||||
let Some((kind, rest)) = fields.split_first() else { continue };
|
||||
match kind.as_str() {
|
||||
"C" => {
|
||||
let (mut value, mut formula, mut shared) = (None, None, None::<(Option<u32>, Option<u32>)>);
|
||||
let mut bad = false;
|
||||
for f in rest {
|
||||
let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8));
|
||||
match tag {
|
||||
"Y" => match position(arg, MAX_ROWS) {
|
||||
Some(r) => row = r,
|
||||
None => bad = true,
|
||||
},
|
||||
"X" => match position(arg, MAX_COLS) {
|
||||
Some(c) => col = c,
|
||||
None => bad = true,
|
||||
},
|
||||
"K" => value = Some(parse_value(arg)),
|
||||
"E" => formula = Some(arg.to_string()),
|
||||
"S" => shared = Some((None, None)),
|
||||
// In a shared-formula cell, R and C name the cell whose formula it shares.
|
||||
"R" if shared.is_some() => shared = shared.map(|(_, c)| (position(arg, MAX_ROWS), c)),
|
||||
"C" if shared.is_some() => shared = shared.map(|(r, _)| (r, position(arg, MAX_COLS))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if bad {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let at = CellRef::new(row, col);
|
||||
if let Some((Some(r), Some(c))) = shared {
|
||||
formula = formulas.get(&CellRef::new(r, c)).cloned();
|
||||
}
|
||||
let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty));
|
||||
if let Some(v) = value {
|
||||
cell.value = v;
|
||||
}
|
||||
if let Some(r1c1) = formula {
|
||||
let a1 = r1c1_formula_to_a1(&r1c1, at);
|
||||
let f = Formula::new(&a1);
|
||||
if f.expr.is_some() {
|
||||
cell.formula = Some(Arc::new(f));
|
||||
formulas.insert(at, r1c1);
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
sheet.set_cell(at, cell);
|
||||
}
|
||||
"F" => {
|
||||
let mut format = None;
|
||||
for f in rest {
|
||||
let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8));
|
||||
match tag {
|
||||
"Y" => row = position(arg, MAX_ROWS).unwrap_or(row),
|
||||
"X" => col = position(arg, MAX_COLS).unwrap_or(col),
|
||||
"P" => format = arg.parse::<usize>().ok(),
|
||||
"W" => {
|
||||
let mut parts = arg.split_whitespace();
|
||||
if let (Some(first), Some(last), Some(width)) = (parts.next(), parts.next(), parts.next())
|
||||
&& let (Some(first), Some(last), Ok(width)) =
|
||||
(position(first, MAX_COLS), position(last, MAX_COLS), width.parse::<f64>())
|
||||
&& first <= last
|
||||
&& (0.0..=255.0).contains(&width)
|
||||
// A file of width records can't make the import loop over every column
|
||||
// again and again.
|
||||
&& widths.len() < MAX_WIDTH_RECORDS
|
||||
{
|
||||
widths.push((first, last, width));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(format) = format {
|
||||
cell_formats.push((CellRef::new(row, col), format));
|
||||
}
|
||||
}
|
||||
// Number formats only: `P;F…` and `P;E…` are fonts.
|
||||
"P" => {
|
||||
if let Some(code) = rest.first().and_then(|f| f.strip_prefix('P')) {
|
||||
formats.push(code.to_string());
|
||||
}
|
||||
}
|
||||
"E" => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for (first, last, width) in widths {
|
||||
let size = crate::cmd::format::chars_to_points(width) as f32;
|
||||
for c in first..=last {
|
||||
sheet.cols.entry(c).or_default().size = Some(size);
|
||||
}
|
||||
}
|
||||
let mut wb = Workbook::new();
|
||||
let mut styles = HashMap::new();
|
||||
for (at, index) in cell_formats {
|
||||
let Some(code) = formats.get(index).filter(|code| !code.is_empty() && *code != "General") else { continue };
|
||||
let style = *styles.entry(index).or_insert_with(|| wb.styles.intern(Style { num_fmt: NumFmt(Arc::from(code.as_str())), ..Style::default() }));
|
||||
let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty));
|
||||
cell.style = style;
|
||||
sheet.set_cell(at, cell);
|
||||
}
|
||||
if let Some(first) = wb.sheet_mut(0) {
|
||||
sheet.name = std::mem::take(&mut first.name);
|
||||
*first = sheet;
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
if skipped > 0 {
|
||||
warnings.push(format!("{name}: {skipped} SYLK records GridCraft couldn't read were skipped"));
|
||||
}
|
||||
Ok((wb, warnings))
|
||||
}
|
||||
|
||||
/// A record's fields: `;` separates them and `;;` is a literal semicolon.
|
||||
fn split_fields(line: &str) -> Vec<String> {
|
||||
let mut fields = vec![String::new()];
|
||||
let mut chars = line.trim_end_matches('\r').chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == ';' {
|
||||
if chars.peek() == Some(&';') {
|
||||
chars.next();
|
||||
if let Some(f) = fields.last_mut() {
|
||||
f.push(';');
|
||||
}
|
||||
} else {
|
||||
fields.push(String::new());
|
||||
}
|
||||
} else if let Some(f) = fields.last_mut() {
|
||||
f.push(c);
|
||||
}
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
/// A 1-based row or column number → 0-based, within `max`.
|
||||
fn position(arg: &str, max: u32) -> Option<u32> {
|
||||
arg.trim().parse::<u32>().ok().filter(|n| (1..=max).contains(n)).map(|n| n - 1)
|
||||
}
|
||||
|
||||
/// A `K` value: `"text"` (everything between the first and the last quote, as written), a
|
||||
/// boolean, an error or a number.
|
||||
fn parse_value(arg: &str) -> Value {
|
||||
if let Some(text) = arg.strip_prefix('"') {
|
||||
let text = text.strip_suffix('"').unwrap_or(text);
|
||||
return Value::Text(Arc::from(text));
|
||||
}
|
||||
if arg.eq_ignore_ascii_case("TRUE") || arg.eq_ignore_ascii_case("FALSE") {
|
||||
return Value::Bool(arg.eq_ignore_ascii_case("TRUE"));
|
||||
}
|
||||
if let Some(e) = CellError::parse(arg) {
|
||||
return Value::Error(e);
|
||||
}
|
||||
match arg.trim().parse::<f64>() {
|
||||
Ok(n) if n.is_finite() => Value::Number(n),
|
||||
_ => Value::Text(Arc::from(arg)),
|
||||
}
|
||||
}
|
||||
|
||||
/// One part of an R1C1 reference: `R`/`C` followed by an absolute number, a bracketed offset or
|
||||
/// nothing (the cell's own row or column).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Part {
|
||||
Absolute(u32),
|
||||
Relative(i64),
|
||||
}
|
||||
|
||||
impl Part {
|
||||
/// The 0-based index and whether it is absolute, or `None` when it falls off the sheet.
|
||||
fn resolve(self, base: u32, max: u32) -> Option<(u32, bool)> {
|
||||
match self {
|
||||
Part::Absolute(n) => (1..=max).contains(&n).then(|| (n - 1, true)),
|
||||
Part::Relative(k) => i64::from(base).checked_add(k).and_then(|v| u32::try_from(v).ok()).filter(|v| *v < max).map(|v| (v, false)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the number after `R` or `C` at `i`: digits, `[offset]` or nothing. Returns the part and
|
||||
/// the index after it, or `None` when the text isn't a reference part.
|
||||
fn parse_part(chars: &[char], i: usize) -> Option<(Part, usize)> {
|
||||
match chars.get(i) {
|
||||
Some('[') => {
|
||||
let close = chars[i..].iter().position(|&c| c == ']')? + i;
|
||||
let inner: String = chars[i + 1..close].iter().collect();
|
||||
Some((Part::Relative(inner.parse().ok()?), close + 1))
|
||||
}
|
||||
Some(c) if c.is_ascii_digit() => {
|
||||
let end = chars[i..].iter().position(|c| !c.is_ascii_digit()).map_or(chars.len(), |p| p + i);
|
||||
let n: String = chars[i..end].iter().collect();
|
||||
Some((Part::Absolute(n.parse().unwrap_or(u32::MAX)), end))
|
||||
}
|
||||
_ => Some((Part::Relative(0), i)),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_word_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || matches!(c, '_' | '.' | '\\' | '?' | '$')
|
||||
}
|
||||
|
||||
/// Rewrites the R1C1 references in a SYLK formula as A1 references for the cell at `at`:
|
||||
/// `RC[-1]` → `B2`, `R1C1` → `$A$1`, `R[-2]C:R[-1]C` → `D2:D3`, a whole column `C4` → `$D:$D` and
|
||||
/// a whole row `R3` → `$3:$3`. Text in quotes and quoted sheet names are left alone; a reference
|
||||
/// off the sheet becomes `#REF!`.
|
||||
pub(crate) fn r1c1_formula_to_a1(formula: &str, at: CellRef) -> String {
|
||||
let chars: Vec<char> = formula.chars().collect();
|
||||
let mut out = String::with_capacity(formula.len());
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
if c == '"' || c == '\'' {
|
||||
// Copy the quoted run as it is; a doubled quote stays inside it.
|
||||
let mut j = i + 1;
|
||||
while j < chars.len() && !(chars[j] == c && chars.get(j + 1) != Some(&c)) {
|
||||
j += if chars[j] == c { 2 } else { 1 };
|
||||
}
|
||||
let stop = (j + 1).min(chars.len());
|
||||
out.extend(&chars[i..stop]);
|
||||
i = stop;
|
||||
continue;
|
||||
}
|
||||
let starts_word = i == 0 || !is_word_char(chars[i - 1]);
|
||||
if starts_word
|
||||
&& matches!(c, 'R' | 'r' | 'C' | 'c')
|
||||
&& let Some((text, next)) = reference_at(&chars, i, at)
|
||||
{
|
||||
out.push_str(&text);
|
||||
i = next;
|
||||
continue;
|
||||
}
|
||||
// Copy the rest of a word as it is, so `ROUND` or `Sheet2` isn't read as a reference.
|
||||
if is_word_char(c) {
|
||||
let end = (i..chars.len()).find(|&j| !is_word_char(chars[j])).unwrap_or(chars.len());
|
||||
out.extend(&chars[i..end]);
|
||||
i = end;
|
||||
} else {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The A1 text of the R1C1 reference starting at `i`, and the index after it, or `None` when the
|
||||
/// word there isn't a reference (a function or a name).
|
||||
fn reference_at(chars: &[char], i: usize, at: CellRef) -> Option<(String, usize)> {
|
||||
let mut j = i;
|
||||
let mut row = None;
|
||||
let mut col = None;
|
||||
if matches!(chars.get(j), Some('R' | 'r')) {
|
||||
let (part, next) = parse_part(chars, j + 1)?;
|
||||
row = Some(part);
|
||||
j = next;
|
||||
}
|
||||
if matches!(chars.get(j), Some('C' | 'c')) {
|
||||
let (part, next) = parse_part(chars, j + 1)?;
|
||||
col = Some(part);
|
||||
j = next;
|
||||
}
|
||||
if row.is_none() && col.is_none() {
|
||||
return None;
|
||||
}
|
||||
// A reference ends the word: `ROUND(`, `RATE` or `R2D2` are names.
|
||||
if chars.get(j).is_some_and(|&c| is_word_char(c) || c == '(' || c == '[' || c == '!') {
|
||||
return None;
|
||||
}
|
||||
let row_text = |p: Part| p.resolve(at.row, MAX_ROWS).map(|(r, abs)| format!("{}{}", if abs { "$" } else { "" }, r + 1));
|
||||
let col_text = |p: Part| p.resolve(at.col, MAX_COLS).map(|(c, abs)| format!("{}{}", if abs { "$" } else { "" }, col_to_letters(c)));
|
||||
// One side of a range (`R2:R4`, `C1:C3`) is written alone; a lone row or column is the whole of it.
|
||||
let prev = chars[..i].iter().rev().find(|c| !c.is_whitespace());
|
||||
let in_range = prev == Some(&':') || chars[j..].iter().find(|c| !c.is_whitespace()) == Some(&':');
|
||||
let text = match (row, col) {
|
||||
(Some(r), Some(c)) => row_text(r).zip(col_text(c)).map(|(r, c)| format!("{c}{r}")),
|
||||
(Some(r), None) => row_text(r).map(|r| if in_range { r } else { format!("{r}:{r}") }),
|
||||
(None, Some(c)) => col_text(c).map(|c| if in_range { c } else { format!("{c}:{c}") }),
|
||||
(None, None) => None,
|
||||
};
|
||||
Some((text.unwrap_or_else(|| "#REF!".into()), j))
|
||||
}
|
||||
@@ -604,3 +604,160 @@ fn opening_google_sheets_export_unwraps_only_formulas_gridcraft_calculates() {
|
||||
assert_eq!(text("A2").as_deref(), Some("IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")"));
|
||||
assert_eq!(opened.sheets[0].cells.get(CellRef::parse("A2").unwrap()).map(|c| c.value.clone()), Some(Value::text("salut")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_web_page_saved_as_xls_opens_its_table() {
|
||||
// #3166: a web app's "Export to Excel" is an HTML table saved as .xls; it opened as lines of
|
||||
// markup. Excel opens the table: values read as typed, <th> bold and centred, <x:Name> names
|
||||
// the sheet.
|
||||
let page = "\u{feff}<html xmlns:x=\"urn:schemas-microsoft-com:office:excel\"><head><meta charset=\"UTF-8\">\
|
||||
<!--[if gte mso 9]><xml><x:ExcelWorksheets><x:ExcelWorksheet><x:Name>Orders</x:Name></x:ExcelWorksheet></x:ExcelWorksheets></xml><![endif]-->\
|
||||
</head><body><table><thead><tr><th>Item</th><th>Qty</th><th>Code</th></tr></thead><tbody>\
|
||||
<tr><td>Kiwi &\n Lime</td><td>12</td><td>007</td></tr><tr><td>Plum</td><td x:num=\"4.5\">4,50</td><td>042</td></tr>\
|
||||
</tbody></table></body></html>";
|
||||
let (wb, warnings) = open_bytes("orders.xls", page.as_bytes()).unwrap();
|
||||
assert!(warnings.first().is_some_and(|w| w.contains("is a web page (HTML)")), "{warnings:?}");
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
assert_eq!(sh.name, "Orders");
|
||||
let v = |a: &str| sh.value(CellRef::parse(a).unwrap());
|
||||
assert_eq!(v("A1"), gridcraft_core::Value::text("Item"));
|
||||
assert_eq!(v("A2"), gridcraft_core::Value::text("Kiwi & Lime"));
|
||||
assert_eq!(v("B2"), gridcraft_core::Value::number(12.0));
|
||||
assert_eq!(v("C2"), gridcraft_core::Value::number(7.0), "read like typed input, as Excel does");
|
||||
assert_eq!(v("B3"), gridcraft_core::Value::number(4.5), "the number the page recorded");
|
||||
let style = |a: &str| wb.styles.get(sh.style_id(CellRef::parse(a).unwrap())).clone();
|
||||
assert!(style("A1").font.bold && style("A1").align.h == gridcraft_model::HAlign::Center);
|
||||
assert!(!style("A2").font.bold);
|
||||
// Opened as text, codes keep their zeros; a page named .html gets no warning.
|
||||
let (wb, warnings) = open_bytes_with("orders.html", page.as_bytes(), None, true).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("C2").unwrap()), gridcraft_core::Value::text("007"));
|
||||
// Plain text that merely starts with a tag-like word is still read as CSV.
|
||||
let (wb, _) = open_bytes("notes.csv", b"<b>,1\nx,2\n").unwrap();
|
||||
assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::number(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_web_page_table_keeps_its_characters_text_and_header_formats() {
|
||||
// A page saved in Windows-1252 keeps its accented letters (it read as replacement characters).
|
||||
let mut page = b"<html><body><table><tr><th>Caf".to_vec();
|
||||
page.push(0xE9);
|
||||
page.extend_from_slice(b"</th><th>2024-01-15</th></tr><tr><td>Plum</td><td x:num=\"4.5\">4,50</td></tr></table></body></html>");
|
||||
let (wb, _) = open_bytes("menu.xls", &page).unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
assert_eq!(sh.value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::text("Café"));
|
||||
// A date in a header cell stays a date, bold and centred (its format was replaced).
|
||||
let b1 = wb.styles.get(sh.style_id(CellRef::parse("B1").unwrap()));
|
||||
assert!(matches!(sh.value(CellRef::parse("B1").unwrap()), gridcraft_core::Value::Number(_)));
|
||||
assert!(b1.font.bold && b1.align.h == gridcraft_model::HAlign::Center);
|
||||
assert_ne!(b1.num_fmt.as_str(), "General", "the date keeps its date format");
|
||||
assert_eq!(sh.value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::number(4.5));
|
||||
// Opened as text, a cell keeps the text it shows, not the number the page recorded for it.
|
||||
let (wb, _) = open_bytes_with("menu.xls", &page, None, true).unwrap();
|
||||
assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::text("4,50"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_web_page_table_opens_after_a_leading_comment_and_keeps_nonbreaking_spaces() {
|
||||
// An exporter's comment before <html> still makes a web page; ` ` stays a non-breaking
|
||||
// space, as Excel keeps it, while ordinary whitespace collapses.
|
||||
let page = "<!-- exported by a web app -->\n<html><body><table><tr><td>New York</td><td>a \n b</td></tr></table></body></html>";
|
||||
let (wb, warnings) = open_bytes("cities.xls", page.as_bytes()).unwrap();
|
||||
assert!(warnings.first().is_some_and(|w| w.contains("is a web page (HTML)")), "{warnings:?}");
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
assert_eq!(sh.value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::text("New\u{a0}\u{a0}York"));
|
||||
assert_eq!(sh.value(CellRef::parse("B1").unwrap()), gridcraft_core::Value::text("a b"));
|
||||
}
|
||||
|
||||
/// A small SYLK file written the way spreadsheets export one: a header, number formats, column
|
||||
/// widths, cells that carry on from the last row or column, formulas in R1C1 and a shared formula.
|
||||
const SYLK: &str = "ID;PTEST;N;E\r\nP;PGeneral\r\nP;P0.00\r\nP;Pyyyy\\-mm\\-dd\r\nF;W1 1 18\r\nF;W2 3 9\r\nB;Y5;X7\r\n\
|
||||
C;Y1;X1;K\"Item\"\r\nC;X2;K\"Qty\"\r\nC;X4;K\"Total\"\r\n\
|
||||
C;Y2;X1;K\"Bolts;; small\"\r\nC;X2;K12\r\nF;P1;F$2G;X3\r\nC;X3;K0.35\r\nC;X4;K4.2;ERC[-2]*RC[-1]\r\n\
|
||||
C;X5;K52;ER2C2+R[1]C[-3]\r\nC;X6;KTRUE\r\nC;X7;K8.4;ERC[-3]*2\r\n\
|
||||
C;Y3;X1;K\"Nuts \"\"hex\"\"\"\r\nC;X2;K40\r\nC;X3;K0.12\r\nC;X4;K4.8;ERC[-2]*RC[-1]\r\nC;X5;K\"big\";EIF(R4C4>5,\"big\",\"small\")\r\nC;X7;K9.6;S;R2;C7\r\n\
|
||||
C;Y4;X1;K\"Sum\"\r\nC;X4;K9;ESUM(R[-2]C:R[-1]C)\r\nC;X5;K#DIV/0!;E1/0\r\nC;X7;K18;ESUM(C4)\r\n\
|
||||
F;P2;FG0G;Y5;X2\r\nC;Y5;X2;K45000\r\nC;X3;K\"R2C3\";E\"R2C3\"\r\nE\r\n";
|
||||
|
||||
#[test]
|
||||
fn sylk_files_open_with_values_formulas_formats_and_widths() {
|
||||
let (wb, warnings) = open_bytes("Parts list.slk", SYLK.as_bytes()).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
assert_eq!(sh.name, "Parts list", "the sheet is named after the file");
|
||||
let at = |a: &str| CellRef::parse(a).unwrap();
|
||||
let formula = |a: &str| sh.cell(at(a)).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone());
|
||||
let text = |s: &str| gridcraft_core::Value::Text(s.into());
|
||||
assert_eq!(sh.value(at("A1")), text("Item"));
|
||||
assert_eq!(sh.value(at("A2")), text("Bolts; small"), ";; is a semicolon");
|
||||
assert_eq!(sh.value(at("A3")), text("Nuts \"\"hex\"\""), "text is kept between the outer quotes as written");
|
||||
assert_eq!(sh.value(at("B2")), gridcraft_core::Value::Number(12.0));
|
||||
assert_eq!(sh.value(at("F2")), gridcraft_core::Value::Bool(true));
|
||||
assert_eq!(sh.value(at("E4")), gridcraft_core::Value::Error(gridcraft_core::CellError::Div0));
|
||||
assert_eq!(sh.value(at("D4")), gridcraft_core::Value::Number(9.0), "a formula keeps the value the file cached");
|
||||
assert_eq!(formula("D2").as_deref(), Some("B2*C2"));
|
||||
assert_eq!(formula("E2").as_deref(), Some("$B$2+B3"));
|
||||
assert_eq!(formula("E3").as_deref(), Some("IF($D$4>5,\"big\",\"small\")"));
|
||||
assert_eq!(formula("D4").as_deref(), Some("SUM(D2:D3)"));
|
||||
assert_eq!(formula("G3").as_deref(), Some("D3*2"), "a shared formula is the other cell's, moved");
|
||||
assert_eq!(formula("G4").as_deref(), Some("SUM($D:$D)"), "C4 alone is the whole column");
|
||||
assert_eq!(formula("C5").as_deref(), Some("\"R2C3\""), "text in quotes isn't a reference");
|
||||
// `F;P1;X3` formats the cell on the current row only.
|
||||
let fmt = |a: &str| wb.styles.get(sh.style_id(at(a))).num_fmt.0.to_string();
|
||||
assert_eq!(fmt("C2"), "0.00");
|
||||
assert_eq!(fmt("C3"), "General");
|
||||
assert_eq!(fmt("B5"), "yyyy\\-mm\\-dd");
|
||||
// Widths are in characters, like the Column Width dialog's.
|
||||
let chars = |c: u32| gridcraft_model::points_to_chars(f64::from(sh.col_width(c)));
|
||||
assert_eq!((chars(0), chars(1), chars(2)), (18.0, 9.0, 9.0));
|
||||
assert_eq!(sh.cols.get(&3).and_then(|i| i.size), None, "column D keeps the default width");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sylk_r1c1_formulas_become_a1() {
|
||||
let b2 = CellRef::parse("B2").unwrap();
|
||||
for (r1c1, a1) in [
|
||||
("RC", "B2"),
|
||||
("R[1]C[-1]", "A3"),
|
||||
("R1C1:R2C2", "$A$1:$B$2"),
|
||||
("R3", "$3:$3"),
|
||||
("R1:R[2]", "$1:4"),
|
||||
("C[1]", "C:C"),
|
||||
("SUM(C3:C4)", "SUM($C:$D)"),
|
||||
("ROUND(RC[1],2)+RATE(1,2,3)", "ROUND(C2,2)+RATE(1,2,3)"),
|
||||
("'R1C1 sheet'!R1C1", "'R1C1 sheet'!$A$1"),
|
||||
("Sheet2!RC", "Sheet2!B2"),
|
||||
("R2D2+rc", "R2D2+B2"),
|
||||
("R[-5]C", "#REF!"),
|
||||
("\"RC\"&R1C1", "\"RC\"&$A$1"),
|
||||
] {
|
||||
assert_eq!(sylk::r1c1_formula_to_a1(r1c1, b2), a1, "{r1c1}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sylk_rejects_other_files_and_survives_damage() {
|
||||
assert!(open_bytes("not.slk", b"A,B\n1,2\n").is_err(), "a SYLK file starts with ID");
|
||||
// Positions off the sheet, broken numbers, a missing end and a format past the list.
|
||||
let (wb, warnings) =
|
||||
open_bytes("damaged.slk", b"ID;P\nC;Y0;X1;K1\nC;Y2;X99999;K2\nC;Y3;X1;K1e999\nF;P7;Y3;X1\nF;W5 2 9\nC;Y4;X1;K5;E(((\n").unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
assert_eq!(sh.cells.iter().count(), 2, "{:?}", sh.cells.iter().collect::<Vec<_>>());
|
||||
assert_eq!(sh.value(CellRef::parse("A3").unwrap()), gridcraft_core::Value::Text("1e999".into()));
|
||||
assert_eq!(warnings.len(), 1, "skipped records are reported: {warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sylk_survives_huge_relative_offsets_and_width_floods() {
|
||||
// Relative offsets at the edge of i64 fall off the sheet instead of overflowing.
|
||||
let text = b"ID;P\nC;Y1;X1;K0;ER[9223372036854775807]C\nC;Y2;X1;K0;ER[-9223372036854775808]C[-9223372036854775808]\nE\n";
|
||||
assert!(open_bytes("offsets.slk", text).is_ok());
|
||||
// Width records over every column, many times over: the import keeps a bounded number.
|
||||
let mut flood = b"ID;P\n".to_vec();
|
||||
for _ in 0..5000 {
|
||||
flood.extend_from_slice(b"F;W1 16384 12\n");
|
||||
}
|
||||
flood.extend_from_slice(b"C;Y1;X1;K1\nE\n");
|
||||
let (wb, _) = open_bytes("widths.slk", &flood).unwrap();
|
||||
assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::Number(1.0));
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@ struct AggregateInput<'a> {
|
||||
extra: &'a [usize],
|
||||
filters: &'a [(usize, &'a PivotFilter, PivotDateGroup, bool)],
|
||||
filter_fmts: &'a [String],
|
||||
/// Each filter's selected labels, ASCII-lowercased, so a record's test is one probe.
|
||||
filter_sets: &'a [Option<HashSet<String>>],
|
||||
row_fmts: &'a [String],
|
||||
col_fmts: &'a [String],
|
||||
empty: bool,
|
||||
@@ -99,10 +101,10 @@ fn aggregate_record(
|
||||
ri: usize,
|
||||
rec: &[Value],
|
||||
) -> Result<(), String> {
|
||||
for ((c, field, group, _), fmt) in input.filters.iter().zip(input.filter_fmts) {
|
||||
if let Some(selected) = &field.selected {
|
||||
for (((c, _, group, _), fmt), selected) in input.filters.iter().zip(input.filter_fmts).zip(input.filter_sets) {
|
||||
if let Some(selected) = selected {
|
||||
let label = item_label(input.wb, rec.get(*c).unwrap_or(&Value::Empty), *group, fmt);
|
||||
if !selected.iter().any(|value| value.eq_ignore_ascii_case(&label)) {
|
||||
if !selected.contains(&label.to_ascii_lowercase()) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -355,15 +357,19 @@ fn ranked_value(raw: Value, lookup: &ValueLookup<'_>, context: &ShowContext<'_>)
|
||||
let Some((_, parent)) = context.row_path.split_last() else { return Value::Empty };
|
||||
let Some(me) = raw.as_f64() else { return raw };
|
||||
let siblings = context.siblings.get(parent).cloned().unwrap_or_default();
|
||||
let mut rank = 1u64;
|
||||
for sibling in siblings {
|
||||
let mut path = parent.to_vec();
|
||||
path.push(sibling);
|
||||
if number(&lookup.get(&path, context.col_path, context.value_index)).is_some_and(|value| value > me) {
|
||||
rank += 1;
|
||||
}
|
||||
}
|
||||
Value::number(rank as f64)
|
||||
// Excel's ranks are dense: ties share a rank and the next value takes the next one
|
||||
// (5, 4, 3, 3, 4 rank 1, 2, 3, 3, 2), so count the distinct larger values.
|
||||
let mut larger: Vec<f64> = siblings
|
||||
.into_iter()
|
||||
.filter_map(|sibling| {
|
||||
let mut path = parent.to_vec();
|
||||
path.push(sibling);
|
||||
number(&lookup.get(&path, context.col_path, context.value_index)).filter(|value| *value > me)
|
||||
})
|
||||
.collect();
|
||||
larger.sort_by(f64::total_cmp);
|
||||
larger.dedup();
|
||||
Value::number(larger.len() as f64 + 1.0)
|
||||
}
|
||||
|
||||
fn show_value(definition: &PivotValue, raw: Value, lookup: &ValueLookup<'_>, context: &ShowContext<'_>) -> Value {
|
||||
@@ -471,6 +477,8 @@ pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut pro
|
||||
|
||||
// ---- aggregate
|
||||
let filter_fmts: Vec<String> = filters.iter().map(|f| fmt_of(f.0)).collect();
|
||||
let filter_sets: Vec<Option<HashSet<String>>> =
|
||||
filters.iter().map(|(_, field, _, _)| field.selected.as_ref().map(|s| s.iter().map(|v| v.to_ascii_lowercase()).collect())).collect();
|
||||
let row_fmts: Vec<String> = rows.iter().map(|(c, _)| fmt_of(*c)).collect();
|
||||
let col_fmts: Vec<String> = cols.iter().map(|(c, _)| fmt_of(*c)).collect();
|
||||
let aggregate = aggregate_source(
|
||||
@@ -484,6 +492,7 @@ pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut pro
|
||||
extra: &extra,
|
||||
filters: &filters,
|
||||
filter_fmts: &filter_fmts,
|
||||
filter_sets: &filter_sets,
|
||||
row_fmts: &row_fmts,
|
||||
col_fmts: &col_fmts,
|
||||
empty,
|
||||
|
||||
@@ -197,7 +197,8 @@ impl Acc {
|
||||
}
|
||||
}
|
||||
|
||||
/// Excel's caption verb for a function ("Sum of Sales").
|
||||
/// Excel's caption verb for a function ("Sum of Sales"). Count Numbers is captioned "Count of
|
||||
/// Sales", as Excel names it and as the XLSX reader names a value field saved without a name.
|
||||
pub fn func_caption(f: PivotFunc) -> &'static str {
|
||||
match f {
|
||||
PivotFunc::Sum => "Sum",
|
||||
@@ -206,7 +207,7 @@ pub fn func_caption(f: PivotFunc) -> &'static str {
|
||||
PivotFunc::Max => "Max",
|
||||
PivotFunc::Min => "Min",
|
||||
PivotFunc::Product => "Product",
|
||||
PivotFunc::CountNumbers => "Count Numbers",
|
||||
PivotFunc::CountNumbers => "Count",
|
||||
PivotFunc::StdDev => "StdDev",
|
||||
PivotFunc::StdDevP => "StdDevp",
|
||||
PivotFunc::Var => "Var",
|
||||
|
||||
@@ -128,6 +128,32 @@ impl Selection {
|
||||
}
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
/// Ctrl+. (Next Corner of Selection): the active cell moves clockwise to the next corner of
|
||||
/// the current area (top-left, top-right, bottom-right, bottom-left) and becomes the anchor,
|
||||
/// so Shift+arrows then move the opposite corner. The area stays selected. From a cell that
|
||||
/// isn't a corner the next corner is the top-left one; a single cell stays where it is.
|
||||
pub fn next_corner(&mut self) {
|
||||
self.next_corner_by(Some);
|
||||
}
|
||||
|
||||
/// [`Selection::next_corner`] where `stop` says where each corner takes the active cell: the
|
||||
/// top-left cell of a merged cell the corner is in, or `None` for a corner that can't be
|
||||
/// selected (it is skipped). Corners that end up on the active cell are one stop with it.
|
||||
pub fn next_corner_by(&mut self, stop: impl Fn(CellRef) -> Option<CellRef>) {
|
||||
let r = self.current();
|
||||
let corners = [r.start, CellRef::new(r.start.row, r.end.col), r.end, CellRef::new(r.end.row, r.start.col)];
|
||||
let next = match corners.iter().rposition(|c| stop(*c) == Some(self.active)) {
|
||||
Some(i) => (1..=4).filter_map(|k| corners.get((i + k) % 4)).filter_map(|c| stop(*c)).find(|c| *c != self.active),
|
||||
None => corners.iter().find_map(|c| stop(*c)),
|
||||
};
|
||||
if let Some(next) = next {
|
||||
self.active = next;
|
||||
self.anchor = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ctrl+arrow: jump to the edge of the current data region (Excel's End mode).
|
||||
pub fn jump(sheet: &Sheet, from: CellRef, dr: i64, dc: i64) -> CellRef {
|
||||
if dr == 0 && dc == 0 {
|
||||
|
||||
@@ -2743,6 +2743,78 @@ fn validation_formulas_are_relative_and_see_the_new_value() {
|
||||
assert!(!ok(&mut s, "C1", "50"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_area_rule_formulas_are_relative_to_the_top_left_cell_of_all_areas() {
|
||||
// Issue #3152: Excel writes the formula for the top-left cell of all the areas (H26), whatever
|
||||
// order they are listed in, not for the first area listed (H62).
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "F26:F27", "values": [[4], [5]]})).unwrap();
|
||||
s.execute("data.validation", json!({"range": "H62:H68,H26:H27,H93:H95", "type": "custom", "formula1": "=MOD($H26,$F26)=0"})).unwrap();
|
||||
let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true;
|
||||
assert!(ok(&mut s, "H26", "4"));
|
||||
assert!(ok(&mut s, "H26", "8"));
|
||||
assert!(!ok(&mut s, "H26", "3"));
|
||||
assert!(ok(&mut s, "H27", "10"));
|
||||
assert!(!ok(&mut s, "H27", "4"));
|
||||
|
||||
// Conditional formats anchor on the same cell: `=D2>0` on D5:D6,D2:D3 checks D2 at D2.
|
||||
s.execute("range.setValues", json!({"range": "D2:D6", "values": [[1], [0], [0], [1], [0]]})).unwrap();
|
||||
s.execute("home.conditionalFormat", json!({"range": "D5:D6,D2:D3", "rule": {"type": "expression", "formula": "=D2>0", "preset": "greenFill"}}))
|
||||
.unwrap();
|
||||
let wb = s.doc().unwrap().wb.clone();
|
||||
let mut cache = crate::cf::CfCache::new();
|
||||
let mut styled = |c: &str| cache.look(&wb, 0, CellRef::parse(c).unwrap()).and_then(|l| l.style).is_some();
|
||||
assert_eq!([styled("D2"), styled("D3"), styled("D5"), styled("D6")], [true, false, true, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_a_rules_top_left_cell_rewrites_its_formulas_for_the_new_one() {
|
||||
let mut s = s();
|
||||
let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true;
|
||||
let f1 = |s: &Session| s.doc().unwrap().wb.active().unwrap().validations[0].f1.clone();
|
||||
|
||||
// One area: clearing A1 off A1:A9 leaves A2:A9, whose cells still check themselves.
|
||||
s.execute("data.validation", json!({"range": "A1:A9", "type": "custom", "formula1": "=A1>10"})).unwrap();
|
||||
s.execute("data.validation", json!({"range": "A1", "clear": true})).unwrap();
|
||||
assert_eq!(f1(&s), "A2>10");
|
||||
assert!(ok(&mut s, "A2", "20"));
|
||||
assert!(!ok(&mut s, "A2", "5"));
|
||||
s.execute("data.validation", json!({"range": "A1:A9", "clear": true})).unwrap();
|
||||
|
||||
// Several areas: clearing H26 moves the top-left cell to H27, where `$H27,$F27` is checked.
|
||||
s.execute("range.setValues", json!({"range": "F26:F27", "values": [[4], [5]]})).unwrap();
|
||||
s.execute("data.validation", json!({"range": "H62:H68,H26:H27", "type": "custom", "formula1": "=MOD($H26,$F26)=0"})).unwrap();
|
||||
s.execute("data.validation", json!({"range": "H26", "clear": true})).unwrap();
|
||||
assert_eq!(f1(&s), "MOD($H27,$F27)=0");
|
||||
assert!(ok(&mut s, "H27", "10"));
|
||||
assert!(!ok(&mut s, "H27", "4"));
|
||||
// Absolute and literal formulas don't move.
|
||||
s.execute("data.validation", json!({"range": "J1:J3", "type": "list", "formula1": "=$B$1:$B$2"})).unwrap();
|
||||
s.execute("data.validation", json!({"range": "J1", "clear": true})).unwrap();
|
||||
let dv = s.doc().unwrap().wb.active().unwrap().validations.iter().find(|d| d.ranges[0].a1() == "J2:J3").unwrap().f1.clone();
|
||||
assert_eq!(dv, "$B$1:$B$2");
|
||||
|
||||
// Conditional formats: Clear Rules on D2 drops the D2:D3 area, and D5:D6 keeps reading itself.
|
||||
s.execute("range.setValues", json!({"range": "D2:D6", "values": [[1], [0], [0], [0], [1]]})).unwrap();
|
||||
s.execute("home.conditionalFormat", json!({"range": "D5:D6,D2:D3", "rule": {"type": "expression", "formula": "=D2>0", "preset": "greenFill"}}))
|
||||
.unwrap();
|
||||
s.execute("range.setValues", json!({"range": "E2:E6", "values": [[1], [0], [0], [0], [1]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "G2:G6", "values": [[0], [9], [9], [9], [0]]})).unwrap();
|
||||
s.execute(
|
||||
"home.conditionalFormat",
|
||||
json!({"range": "E5:E6,E2:E3", "rule": {"type": "cellIs", "operator": "greater", "value": "=G2", "preset": "lightRedFill"}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("home.clearRules", json!({"range": "D2,E2"})).unwrap();
|
||||
let rules: Vec<gridcraft_model::CfRule> = s.doc().unwrap().wb.active().unwrap().cond_formats.iter().map(|c| c.rule.clone()).collect();
|
||||
assert!(rules.iter().any(|r| matches!(r, gridcraft_model::CfRule::Expression { formula, .. } if formula == "D5>0")), "{rules:?}");
|
||||
assert!(rules.iter().any(|r| matches!(r, gridcraft_model::CfRule::CellIs { a, .. } if a == "G5")), "{rules:?}");
|
||||
let wb = s.doc().unwrap().wb.clone();
|
||||
let mut cache = crate::cf::CfCache::new();
|
||||
let mut styled = |c: &str| cache.look(&wb, 0, CellRef::parse(c).unwrap()).and_then(|l| l.style).is_some();
|
||||
assert_eq!([styled("D5"), styled("D6"), styled("E5"), styled("E6")], [false, true, false, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_build() {
|
||||
for (name, _) in crate::sample::SAMPLES {
|
||||
@@ -5225,6 +5297,42 @@ fn move_columns_checks_the_protection_of_the_named_sheet() {
|
||||
assert_eq!(v(&s, "A1"), Value::text("A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_columns_checks_the_protection_of_a_sheet_named_by_its_range() {
|
||||
// A `Sheet!` range or cell picks the sheet the move modifies; the protection checks must
|
||||
// authorize that sheet too, not the active one.
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({"name": "P"})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4]]})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
for key in ["range", "cell"] {
|
||||
let err = s.execute("sheet.moveColumns", json!({key: "P!A1", "cols": "B:C", "before": 4})).expect_err("moving columns on a protected sheet");
|
||||
assert!(err.to_string().contains("protected"), "{err}");
|
||||
}
|
||||
let d = s.doc().unwrap();
|
||||
let sh = &d.wb.sheets[d.wb.sheet_index("P").unwrap()];
|
||||
let pv: Vec<Value> = (0..4).map(|c| sh.value(CellRef::new(0, c))).collect();
|
||||
assert_eq!(pv, vec![Value::number(1.0), Value::number(2.0), Value::number(3.0), Value::number(4.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_cannot_unlock_a_locked_cell_by_moving_equal_content_onto_it() {
|
||||
// An unlocked 2 sorted onto a locked 2 leaves the content as it was, but would make the cell
|
||||
// editable; the sort is refused instead.
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[2], [2], [1]]})).unwrap();
|
||||
s.execute("home.lockCell", json!({"range": "A1", "on": false})).unwrap();
|
||||
s.execute("home.lockCell", json!({"range": "A3", "on": false})).unwrap();
|
||||
s.execute("review.protectSheet", json!({"sort": true})).unwrap();
|
||||
let before = s.doc().unwrap().wb.clone();
|
||||
let err =
|
||||
s.execute("data.sort", json!({"range": "A1:A3", "header": false, "keys": [{"column": "A", "order": "asc"}]})).expect_err("sort unlocking A2");
|
||||
assert!(err.to_string().contains("protected sheet"), "{err}");
|
||||
assert_eq!(*s.doc().unwrap().wb, *before);
|
||||
assert!(s.execute("cell.set", json!({"cell": "A2", "input": "9"})).is_err(), "A2 stays locked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_cannot_switch_windows() {
|
||||
// Stored scripts are scoped to their workbook: a step that activates another window would
|
||||
@@ -5349,3 +5457,118 @@ fn a_view_kept_through_redo_doesnt_make_the_saved_workbook_dirty() {
|
||||
assert_eq!(d.wb.active().unwrap().view, gridcraft_model::SheetViewMode::PageBreakPreview);
|
||||
assert!(!d.is_dirty(), "back at the saved edits, the view alone isn't a change");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbook_display_options_are_commands() {
|
||||
// #3101: Show sheet tabs / horizontal / vertical scroll bar, toggling without `on`, saved with
|
||||
// the workbook and undoable.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let view = |s: &Session| s.doc().unwrap().wb.view;
|
||||
assert_eq!(s.execute("view.showSheetTabs", json!({})).unwrap()["on"], json!(false));
|
||||
assert_eq!(s.execute("view.showHorizontalScrollBar", json!({"on": false})).unwrap()["on"], json!(false));
|
||||
assert_eq!(s.execute("view.showVerticalScrollBar", json!({"on": false})).unwrap()["on"], json!(false));
|
||||
let v = view(&s);
|
||||
assert!(!v.show_sheet_tabs && !v.show_horizontal_scroll && !v.show_vertical_scroll);
|
||||
let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&s.doc().unwrap().wb).unwrap()).unwrap();
|
||||
assert_eq!(back.view, v, "saved with the workbook");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(view(&s).show_vertical_scroll);
|
||||
assert_eq!(s.execute("view.showSheetTabs", json!({})).unwrap()["on"], json!(true), "toggles back");
|
||||
}
|
||||
|
||||
/// A link from a file can sit on a cell with nothing stored in it, past the used range (or on a
|
||||
/// sheet with no cells at all). Clear Hyperlinks, and Remove Hyperlink in the cell menu, must
|
||||
/// still remove it.
|
||||
#[test]
|
||||
fn clear_hyperlinks_removes_links_on_cells_with_nothing_stored() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
|
||||
let link = || gridcraft_model::Hyperlink { target: "https://example.com".into(), tooltip: None, external: true };
|
||||
let (a1, c5) = (CellRef::parse("A1").unwrap(), CellRef::parse("C5").unwrap());
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().hyperlinks.insert(c5, link());
|
||||
s.execute("edit.clearHyperlinks", json!({"range": "C5"})).unwrap();
|
||||
assert!(!s.doc().unwrap().wb.active().unwrap().hyperlinks.contains_key(&c5), "the link past the used range is removed");
|
||||
|
||||
// No cells at all.
|
||||
s.execute("edit.clearAll", json!({"range": "A1"})).unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().hyperlinks.insert(a1, link());
|
||||
s.execute("edit.clearHyperlinks", json!({"range": "A1"})).unwrap();
|
||||
assert!(!s.doc().unwrap().wb.active().unwrap().hyperlinks.contains_key(&a1), "the link on an empty sheet is removed");
|
||||
}
|
||||
|
||||
/// Notes and links aren't cell contents, so the cell comparison that guards a protected sheet
|
||||
/// doesn't see them go: Clear Hyperlinks and Clear Comments refuse locked cells themselves,
|
||||
/// stored or not.
|
||||
#[test]
|
||||
fn clearing_links_and_notes_respects_sheet_protection() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
|
||||
let link = || gridcraft_model::Hyperlink { target: "https://example.com".into(), tooltip: None, external: true };
|
||||
let (a1, c5) = (CellRef::parse("A1").unwrap(), CellRef::parse("C5").unwrap());
|
||||
{
|
||||
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
|
||||
sh.hyperlinks.insert(a1, link());
|
||||
sh.hyperlinks.insert(c5, link());
|
||||
}
|
||||
s.execute("review.newNote", json!({"cell": "C5", "text": "note", "author": "Example"})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
for (cmd, range) in [("edit.clearHyperlinks", "A1"), ("edit.clearHyperlinks", "C5"), ("edit.clearComments", "C5")] {
|
||||
assert!(s.execute(cmd, json!({"range": range})).is_err(), "{cmd} on locked {range}");
|
||||
}
|
||||
let sh = s.doc().unwrap().wb.active().unwrap().clone();
|
||||
assert!(sh.hyperlinks.contains_key(&a1) && sh.hyperlinks.contains_key(&c5) && sh.comments.contains_key(&c5), "nothing removed");
|
||||
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
s.execute("edit.clearHyperlinks", json!({"range": "A1:C5"})).unwrap();
|
||||
s.execute("edit.clearComments", json!({"range": "C5"})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap().clone();
|
||||
assert!(sh.hyperlinks.is_empty() && sh.comments.is_empty());
|
||||
}
|
||||
|
||||
/// A removed link or note is a change to its cell even when nothing is stored there, so a
|
||||
/// recorded macro's footprint lists it.
|
||||
#[test]
|
||||
fn clearing_links_and_notes_records_their_cells() {
|
||||
let mut s = s();
|
||||
let c5 = CellRef::parse("C5").unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb)
|
||||
.sheet_mut(0)
|
||||
.unwrap()
|
||||
.hyperlinks
|
||||
.insert(c5, gridcraft_model::Hyperlink { target: "https://example.com".into(), tooltip: None, external: true });
|
||||
s.execute("review.newNote", json!({"cell": "D7", "text": "note", "author": "Example"})).unwrap();
|
||||
s.doc_mut().unwrap().touched = Some(Vec::new());
|
||||
s.execute("edit.clearHyperlinks", json!({"range": "C5"})).unwrap();
|
||||
s.execute("edit.clearComments", json!({"range": "D7"})).unwrap();
|
||||
let touched: Vec<String> = s.doc().unwrap().touched.clone().unwrap_or_default().into_iter().map(|(_, c)| c.a1()).collect();
|
||||
assert!(touched.contains(&"C5".to_string()) && touched.contains(&"D7".to_string()), "{touched:?}");
|
||||
}
|
||||
|
||||
/// The locked-cell check for notes and links keeps Allow Edit Ranges: a range without a password
|
||||
/// allows them at once, one with a password once it's unlocked in this session.
|
||||
#[test]
|
||||
fn clearing_links_and_notes_keeps_allow_edit_ranges() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
|
||||
let link = || gridcraft_model::Hyperlink { target: "https://example.com".into(), tooltip: None, external: true };
|
||||
let (a1, c5) = (CellRef::parse("A1").unwrap(), CellRef::parse("C5").unwrap());
|
||||
{
|
||||
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
|
||||
sh.hyperlinks.insert(a1, link());
|
||||
sh.hyperlinks.insert(c5, link());
|
||||
}
|
||||
s.execute("review.newNote", json!({"cell": "C5", "text": "note", "author": "Example"})).unwrap();
|
||||
// Ranges are added before protecting: a protected sheet refuses `add`.
|
||||
s.execute("review.allowEditRanges", json!({"action": "add", "title": "Open", "range": "A1"})).unwrap();
|
||||
s.execute("review.allowEditRanges", json!({"action": "add", "title": "Kept", "range": "C5", "password": "example"})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
|
||||
s.execute("edit.clearHyperlinks", json!({"range": "A1"})).unwrap();
|
||||
assert!(s.execute("edit.clearComments", json!({"range": "C5"})).is_err(), "a password range stays locked until unlocked");
|
||||
s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "Kept", "password": "example"})).unwrap();
|
||||
s.execute("edit.clearHyperlinks", json!({"range": "C5"})).unwrap();
|
||||
s.execute("edit.clearComments", json!({"range": "C5"})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap().clone();
|
||||
assert!(sh.hyperlinks.is_empty() && sh.comments.is_empty());
|
||||
}
|
||||
|
||||
@@ -760,6 +760,13 @@ fn groupby(args: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let total = opt_count(args, 4)?.unwrap_or(1);
|
||||
let sort = opt_count(args, 5)?.unwrap_or(1);
|
||||
let filter = opt_arr(args, 6)?;
|
||||
let relationship = opt_count(args, 7)?.unwrap_or(0);
|
||||
// A total depth of N adds subtotals for the first N - 1 key columns, so it can't exceed the
|
||||
// key columns, and a flat table (field_relationship 1) has no levels to subtotal.
|
||||
let depth = total.unsigned_abs() as usize;
|
||||
if depth > rf.cols.max(1) || (depth > 1 && relationship == 1) {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
let p = prepare(&[&rf], &vals, fh, filter.as_ref())?;
|
||||
let keys: Vec<Vec<Value>> = p.rows.iter().map(|&r| key_row(&rf, r)).collect();
|
||||
let mut groups: HashMap<String, Vec<usize>> = HashMap::new();
|
||||
@@ -791,13 +798,22 @@ fn groupby(args: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
lines
|
||||
.sort_by(|a, b| cmp_keys(&[a.get(ci).cloned().unwrap_or(Value::Empty)], &[b.get(ci).cloned().unwrap_or(Value::Empty)], &[sort.signum()]));
|
||||
}
|
||||
if depth > 1 {
|
||||
let mut rows_of = |keys: &[Value]| groups.get(&row_key(keys)).cloned().unwrap_or_default();
|
||||
// The header and total lines count too, so the whole result is checked before it is built.
|
||||
let around = usize::from(p.show_headers) + usize::from(total != 0);
|
||||
let width = rf.cols.saturating_add(vals.cols);
|
||||
let fits = |n: usize| check_output_size(n.saturating_add(around), width);
|
||||
lines = with_subtotals(lines, depth, rf.cols, total < 0, &fits, &mut rows_of, &mut agg_row)?;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if p.show_headers {
|
||||
let mut h = p.field_headers.clone();
|
||||
h.extend(p.value_headers.iter().cloned());
|
||||
out.push(h);
|
||||
}
|
||||
let mut total_line = vec![Value::from("Total")];
|
||||
// With subtotals Excel calls the last line "Grand Total".
|
||||
let mut total_line = vec![Value::from(if depth > 1 { "Grand Total" } else { "Total" })];
|
||||
total_line.resize(rf.cols, Value::Empty);
|
||||
total_line.extend(agg_row(&p.rows));
|
||||
if total < 0 {
|
||||
@@ -812,6 +828,84 @@ fn groupby(args: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
from_rows(out, &Value::Empty)
|
||||
}
|
||||
|
||||
/// GROUPBY's subtotals: `lines` (`key_cols` keys, then values) with the lines of each group of a
|
||||
/// key level kept together in the order the groups first appear, and for every key level above
|
||||
/// `depth` a line after the group (before it when `before`): the group's keys, blanks for the deeper
|
||||
/// keys, and the values aggregated over the group's source rows (`rows_of` maps a line's keys to them).
|
||||
///
|
||||
/// No recursion, so a deep total depth can't exhaust the stack: each line gets the id of its key
|
||||
/// prefix at every level (ids count up as prefixes first appear), a stable sort by those ids brings
|
||||
/// each group together, and one walk adds a subtotal where a group starts or ends. `fits` gets the
|
||||
/// line count as the groups are found, so an oversized result is refused before it is built.
|
||||
fn with_subtotals(
|
||||
mut lines: Vec<Vec<Value>>,
|
||||
depth: usize,
|
||||
key_cols: usize,
|
||||
before: bool,
|
||||
fits: &dyn Fn(usize) -> R<()>,
|
||||
rows_of: &mut dyn FnMut(&[Value]) -> Vec<usize>,
|
||||
agg: &mut dyn FnMut(&[usize]) -> Vec<Value>,
|
||||
) -> R<Vec<Vec<Value>>> {
|
||||
let levels = depth.saturating_sub(1);
|
||||
if levels == 0 {
|
||||
return Ok(lines);
|
||||
}
|
||||
let mut ids: HashMap<(usize, String), usize> = HashMap::new();
|
||||
let mut paths: Vec<Vec<usize>> = Vec::with_capacity(lines.len());
|
||||
for line in &lines {
|
||||
let mut parent = usize::MAX;
|
||||
let mut path = Vec::with_capacity(levels);
|
||||
for level in 0..levels {
|
||||
let next = ids.len();
|
||||
parent = *ids.entry((parent, row_key(line.get(level..=level).unwrap_or_default()))).or_insert(next);
|
||||
path.push(parent);
|
||||
}
|
||||
fits(lines.len().saturating_add(ids.len()))?;
|
||||
paths.push(path);
|
||||
}
|
||||
let mut group_rows: Vec<Vec<usize>> = vec![Vec::new(); ids.len()];
|
||||
for (line, path) in lines.iter().zip(&paths) {
|
||||
let rows = rows_of(line.get(..key_cols).unwrap_or_default());
|
||||
for &id in path {
|
||||
if let Some(g) = group_rows.get_mut(id) {
|
||||
g.extend_from_slice(&rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut subtotal = |keys: &[Value], level: usize, id: usize| -> Vec<Value> {
|
||||
let mut line: Vec<Value> = keys.iter().take(level + 1).cloned().collect();
|
||||
line.resize(key_cols, Value::Empty);
|
||||
line.extend(agg(group_rows.get(id).map(Vec::as_slice).unwrap_or_default()));
|
||||
line
|
||||
};
|
||||
let mut order: Vec<usize> = (0..lines.len()).collect();
|
||||
order.sort_by(|&a, &b| paths.get(a).cmp(&paths.get(b)));
|
||||
let mut out = Vec::with_capacity(lines.len().saturating_add(ids.len()));
|
||||
let mut prev: Option<(Vec<Value>, &[usize])> = None;
|
||||
for &i in &order {
|
||||
let (Some(line), Some(path)) = (lines.get_mut(i).map(std::mem::take), paths.get(i)) else { continue };
|
||||
// The first level at which this line leaves the previous line's groups.
|
||||
let split = prev.as_ref().map_or(0, |(_, p)| p.iter().zip(path).take_while(|(a, b)| a == b).count());
|
||||
if before {
|
||||
for (level, &id) in path.iter().enumerate().skip(split) {
|
||||
out.push(subtotal(&line, level, id));
|
||||
}
|
||||
} else if let Some((keys, p)) = &prev {
|
||||
for (level, &id) in p.iter().enumerate().skip(split).rev() {
|
||||
out.push(subtotal(keys, level, id));
|
||||
}
|
||||
}
|
||||
prev = Some((line.get(..key_cols).unwrap_or_default().to_vec(), path));
|
||||
out.push(line);
|
||||
}
|
||||
if !before && let Some((keys, p)) = &prev {
|
||||
for (level, &id) in p.iter().enumerate().rev() {
|
||||
out.push(subtotal(keys, level, id));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Height and width of a PIVOTBY grid: `header_rows` (one per column field, plus the value-name row) and
|
||||
/// `body_rows` (row keys plus the total row), each `lead` row-field cells wide plus `value_cols` cells per column key.
|
||||
fn pivot_dims(header_rows: usize, body_rows: usize, col_keys: usize, value_cols: usize, lead: usize) -> (usize, usize) {
|
||||
@@ -1265,6 +1359,78 @@ mod tests {
|
||||
is_err(ev("GROUPBY", vec![rf(region), av(col(&[1.0])), t("SUM")]), CellError::Value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groupby_subtotals_past_the_size_limit_are_refused_before_they_are_built() {
|
||||
// One row with 4000 key columns at depth 4000 asks for 3999 subtotal lines, each 4001 cells
|
||||
// wide: 16 million cells, over the limit. It is #NUM! before any subtotal line exists (it
|
||||
// used to build them all, nesting 4000 levels deep, and refuse them afterwards).
|
||||
let keys = av(arr(vec![(0..4000).map(|i| nv(f64::from(i))).collect()]));
|
||||
is_err(ev("GROUPBY", vec![keys, av(col(&[1.0])), t("SUM"), empty(), n(4000.0)]), CellError::Num);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groupby_total_depth_adds_subtotals() {
|
||||
// #1456 item 1, values as Excel 365 shows them.
|
||||
let keys2 = || av(arr(vec![vec![tv("a"), tv("x")], vec![tv("a"), tv("y")], vec![tv("b"), tv("x")]]));
|
||||
let vals3 = || av(col(&[1.0, 2.0, 3.0]));
|
||||
let e = Value::Empty;
|
||||
let r = ev("GROUPBY", vec![keys2(), vals3(), t("SUM"), empty(), n(2.0)]);
|
||||
assert_eq!(
|
||||
rows_of(&r),
|
||||
vec![
|
||||
vec![tv("a"), tv("x"), nv(1.0)],
|
||||
vec![tv("a"), tv("y"), nv(2.0)],
|
||||
vec![tv("a"), e.clone(), nv(3.0)],
|
||||
vec![tv("b"), tv("x"), nv(3.0)],
|
||||
vec![tv("b"), e.clone(), nv(3.0)],
|
||||
vec![tv("Grand Total"), e.clone(), nv(6.0)],
|
||||
]
|
||||
);
|
||||
let r = ev("GROUPBY", vec![keys2(), vals3(), t("SUM"), empty(), n(-2.0)]);
|
||||
assert_eq!(
|
||||
rows_of(&r),
|
||||
vec![
|
||||
vec![tv("Grand Total"), e.clone(), nv(6.0)],
|
||||
vec![tv("a"), e.clone(), nv(3.0)],
|
||||
vec![tv("a"), tv("x"), nv(1.0)],
|
||||
vec![tv("a"), tv("y"), nv(2.0)],
|
||||
vec![tv("b"), e.clone(), nv(3.0)],
|
||||
vec![tv("b"), tv("x"), nv(3.0)],
|
||||
]
|
||||
);
|
||||
let keys3 = || {
|
||||
av(arr(vec![
|
||||
vec![tv("a"), tv("x"), tv("p")],
|
||||
vec![tv("a"), tv("x"), tv("q")],
|
||||
vec![tv("a"), tv("y"), tv("p")],
|
||||
vec![tv("b"), tv("x"), tv("p")],
|
||||
]))
|
||||
};
|
||||
let vals4 = || av(col(&[1.0, 2.0, 3.0, 4.0]));
|
||||
let r = ev("GROUPBY", vec![keys3(), vals4(), t("SUM"), empty(), n(3.0)]);
|
||||
let line = |k: [&str; 3], v: f64| k.iter().map(|s| if s.is_empty() { Value::Empty } else { tv(s) }).chain([nv(v)]).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
rows_of(&r),
|
||||
vec![
|
||||
line(["a", "x", "p"], 1.0),
|
||||
line(["a", "x", "q"], 2.0),
|
||||
line(["a", "x", ""], 3.0),
|
||||
line(["a", "y", "p"], 3.0),
|
||||
line(["a", "y", ""], 3.0),
|
||||
line(["a", "", ""], 6.0),
|
||||
line(["b", "x", "p"], 4.0),
|
||||
line(["b", "x", ""], 4.0),
|
||||
line(["b", "", ""], 4.0),
|
||||
line(["Grand Total", "", ""], 10.0),
|
||||
]
|
||||
);
|
||||
// Depth 1 keeps "Total"; a depth past the key columns, or subtotals of a flat table, are errors.
|
||||
let r = ev("GROUPBY", vec![keys2(), vals3(), t("SUM"), empty(), n(1.0)]);
|
||||
assert_eq!(rows_of(&r).last().unwrap(), &vec![tv("Total"), e, nv(6.0)]);
|
||||
is_err(ev("GROUPBY", vec![av(arr(vec![vec![tv("a")], vec![tv("b")]])), av(col(&[1.0, 2.0])), t("SUM"), empty(), n(2.0)]), CellError::Value);
|
||||
is_err(ev("GROUPBY", vec![keys2(), vals3(), t("SUM"), empty(), n(2.0), empty(), empty(), n(1.0)]), CellError::Value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pivotby() {
|
||||
let (region, item, amt) = sales();
|
||||
|
||||
@@ -95,6 +95,10 @@ pub struct CalcSettings {
|
||||
pub max_change: f64,
|
||||
/// Use precision as displayed.
|
||||
pub precision_as_displayed: bool,
|
||||
/// R1C1 reference style (`<calcPr refMode="R1C1"/>`): kept with the file and written back;
|
||||
/// GridCraft itself shows and takes A1 references.
|
||||
#[serde(default)]
|
||||
pub r1c1: bool,
|
||||
}
|
||||
|
||||
impl Default for CalcSettings {
|
||||
@@ -105,6 +109,7 @@ impl Default for CalcSettings {
|
||||
max_iterations: Self::DEFAULT_ITERATIONS,
|
||||
max_change: Self::DEFAULT_CHANGE,
|
||||
precision_as_displayed: false,
|
||||
r1c1: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,6 +205,12 @@ pub struct Workbook {
|
||||
pub calc: CalcSettings,
|
||||
#[serde(default, skip_serializing_if = "is_all")]
|
||||
pub show_objects: ShowObjects,
|
||||
/// Trust Center › Privacy Options › "Remove personal information from file properties on save"
|
||||
/// (`<workbookPr filterPrivacy="1"/>`): kept with the file, and while it is on the author and
|
||||
/// company are left out of the XLSX files GridCraft writes, as Excel leaves them empty. Other
|
||||
/// formats aren't filtered: the workbook's own serialization still holds them.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub remove_personal_info: bool,
|
||||
/// Read and set with [`Workbook::active_sheet`] and [`Workbook::set_active_sheet`].
|
||||
pub active_sheet: ActiveSheet,
|
||||
/// The workbook window's display settings (`<workbookView>`), kept across an open + save.
|
||||
@@ -338,6 +349,7 @@ impl Workbook {
|
||||
date_system: DateSystem::D1900,
|
||||
calc: CalcSettings::default(),
|
||||
show_objects: ShowObjects::All,
|
||||
remove_personal_info: false,
|
||||
active_sheet: ActiveSheet::default(),
|
||||
view: WorkbookView::default(),
|
||||
props: DocProps::default(),
|
||||
|
||||
@@ -107,7 +107,9 @@ impl SheetApp {
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
self.ensure_text_width(ctx);
|
||||
// A keytip control request lives one frame: the ribbon consumes it during `ui`, and
|
||||
// anything that never reached a control (collapsed ribbon, tab switch) is dropped.
|
||||
// anything that never reached a control (collapsed ribbon, tab switch) is dropped. One
|
||||
// made with a switch to the control's tab waits, with its window, for the next `ui` of
|
||||
// that window.
|
||||
self.ui.keytip_invoke = None;
|
||||
// Floating pictures' textures go with their workbook, even when no sheet is painted.
|
||||
let open: Vec<u64> = self.session.documents().iter().map(|d| d.uid).collect();
|
||||
@@ -194,6 +196,9 @@ impl SheetApp {
|
||||
let t0 = now_ms();
|
||||
// A script may have switched windows since the last frame.
|
||||
self.follow_active_window();
|
||||
// This window's key tip request that waited for this frame (its tab or ribbon was just
|
||||
// shown); any other window's request was dropped before it.
|
||||
self.ui.keytip_invoke = self.ui.keytip_invoke_next.take();
|
||||
text_box::before_ui(self, &ctx);
|
||||
// Another window came forward without going through a command (which settles drafts
|
||||
// first): the draft goes into its own window instead of floating over this one. If its
|
||||
|
||||
@@ -48,12 +48,41 @@ impl SheetApp {
|
||||
}
|
||||
|
||||
/// Ribbon and menu Paste: paste the text on the system clipboard, as Cmd/Ctrl+V does. With no
|
||||
/// text there, no host clipboard (web), or the text that was there when GridCraft last copied
|
||||
/// without publishing, GridCraft's own clipboard is pasted.
|
||||
/// text there but a picture, the picture is pasted. With neither, no host clipboard (web), or
|
||||
/// the text that was there when GridCraft last copied without publishing, GridCraft's own
|
||||
/// clipboard is pasted.
|
||||
pub fn paste_from_clipboard(&mut self) {
|
||||
match self.system_clipboard_text() {
|
||||
Some(text) if self.session.clipboard.is_none() || self.clipboard_text_at_copy.as_ref() != Some(&text) => self.paste_external(text),
|
||||
_ => self.run_or_alert("edit.paste", json!({})),
|
||||
Some(_) => self.run_or_alert("edit.paste", json!({})),
|
||||
None => match self.services.paste_image.as_mut().and_then(|read| read()) {
|
||||
Some(image) => self.paste_picture(image),
|
||||
None => self.run_or_alert("edit.paste", json!({})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Paste a picture from the system clipboard (a screenshot, an image copied in a browser) as
|
||||
/// a floating picture whose top-left corner is the active cell, as Excel does.
|
||||
pub fn paste_picture(&mut self, image: image::RgbaImage) {
|
||||
match encode_png(&image) {
|
||||
Ok(png) => self.run_or_alert("insert.picture", json!({"base64": gridcraft_engine::io::base64_encode(&png), "mime": "image/png"})),
|
||||
Err(e) => self.alert(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cmd/Ctrl+V with a picture and no text on the clipboard (egui's `Event::PasteImage`).
|
||||
pub fn paste_color_image(&mut self, image: &egui::ColorImage) {
|
||||
let [w, h] = image.size;
|
||||
// A side too large for u32 is too large to paste; check before copying the pixels.
|
||||
let (w, h) = (u32::try_from(w).unwrap_or(u32::MAX), u32::try_from(h).unwrap_or(u32::MAX));
|
||||
if let Err(e) = check_picture_size(w, h) {
|
||||
return self.alert(e);
|
||||
}
|
||||
let rgba = image.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect();
|
||||
match image::RgbaImage::from_raw(w, h, rgba) {
|
||||
Some(image) => self.paste_picture(image),
|
||||
None => self.alert("The picture on the clipboard can't be read.".into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,3 +104,29 @@ impl SheetApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest clipboard picture pasted, per side and in total (an 8K screenshot fits).
|
||||
const MAX_PICTURE_SIDE: u32 = 16_384;
|
||||
const MAX_PICTURE_PIXELS: u64 = 64 * 1024 * 1024;
|
||||
|
||||
/// Refuse empty clipboard pictures and sizes that would exhaust memory.
|
||||
fn check_picture_size(w: u32, h: u32) -> Result<(), String> {
|
||||
if w == 0 || h == 0 {
|
||||
return Err("The picture on the clipboard is empty.".into());
|
||||
}
|
||||
if w > MAX_PICTURE_SIDE || h > MAX_PICTURE_SIDE || u64::from(w) * u64::from(h) > MAX_PICTURE_PIXELS {
|
||||
return Err("The picture on the clipboard is too large to paste.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PNG bytes of a clipboard picture, refusing sizes that would exhaust memory.
|
||||
fn encode_png(image: &image::RgbaImage) -> Result<Vec<u8>, String> {
|
||||
let (w, h) = image.dimensions();
|
||||
check_picture_size(w, h)?;
|
||||
let mut png = Vec::new();
|
||||
image
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.map_err(|e| format!("The picture on the clipboard can't be pasted: {e}"))?;
|
||||
Ok(png)
|
||||
}
|
||||
|
||||
@@ -163,8 +163,19 @@ impl SheetApp {
|
||||
Ok(Json::Null)
|
||||
}
|
||||
"view.expandFormulaBar" => {
|
||||
self.ui.formula_bar_expanded = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar_expanded);
|
||||
Ok(json!({"on": self.ui.formula_bar_expanded}))
|
||||
// `lines` is where a drag of the bar's bottom edge left it: one line collapses it,
|
||||
// more expands it to that height, which expanding returns to afterwards.
|
||||
if let Some(lines) = p.get("lines").and_then(Json::as_u64) {
|
||||
let lines = usize::try_from(lines).unwrap_or(usize::MAX).clamp(1, crate::formula_bar::MAX_DRAG_LINES);
|
||||
self.ui.formula_bar_expanded = lines > 1;
|
||||
if lines > 1 {
|
||||
self.ui.formula_bar_lines = lines;
|
||||
}
|
||||
Ok(json!({"on": self.ui.formula_bar_expanded, "lines": self.ui.formula_bar_lines}))
|
||||
} else {
|
||||
self.ui.formula_bar_expanded = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar_expanded);
|
||||
Ok(json!({"on": self.ui.formula_bar_expanded}))
|
||||
}
|
||||
}
|
||||
"view.darkMode" => {
|
||||
let current = if self.ui.system_theme { self.effective_dark } else { self.ui.dark };
|
||||
@@ -191,6 +202,10 @@ impl SheetApp {
|
||||
self.ui.autocomplete = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.autocomplete);
|
||||
Ok(json!({"on": self.ui.autocomplete}))
|
||||
}
|
||||
"options.highlightActiveRow" => {
|
||||
self.ui.highlight_active_row = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.highlight_active_row);
|
||||
Ok(json!({"on": self.ui.highlight_active_row}))
|
||||
}
|
||||
"view.interfaceScale" => {
|
||||
// `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped.
|
||||
let percent = match p.get("percent") {
|
||||
|
||||
@@ -59,7 +59,22 @@ impl SheetApp {
|
||||
// Route to the ribbon control that owns this keytip: it opens its dropdown or
|
||||
// runs its click handler with the right params (the engine command alone would
|
||||
// be missing them).
|
||||
keytips::Action::Control(id) => self.ui.keytip_invoke = Some(id),
|
||||
//
|
||||
// A control on another tab (a legacy menu key such as Alt+I, S for the Symbol
|
||||
// gallery) shows its tab first, so the control is drawn and takes the request.
|
||||
keytips::Action::Control(id) => {
|
||||
// A collapsed ribbon draws no controls either: it is expanded the same way.
|
||||
let other_tab = gridcraft_engine::find_command(id).and_then(|c| c.menu.first()).filter(|t| **t != self.ui.ribbon_tab);
|
||||
if other_tab.is_some() || self.ui.ribbon_collapsed {
|
||||
if let Some(tab) = other_tab {
|
||||
self.run_or_alert("ui.ribbonTab", json!({"tab": tab}));
|
||||
}
|
||||
self.ui.ribbon_collapsed = false;
|
||||
self.ui.keytip_invoke_next = Some(id);
|
||||
} else {
|
||||
self.ui.keytip_invoke = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ use super::{Key, Language, Modifiers, SheetApp, Ui, dim, i18n, json, section, ve
|
||||
|
||||
/// Excel's File › Options page: General (display theme, interface language and Show formula bar),
|
||||
/// Editing (AutoComplete) and Save (the autosave checkbox toggling `app.ui.autosave`), plus Formulas
|
||||
/// (calculation mode and iterative calculation) when a document is active. Theme, language, formula
|
||||
/// (calculation mode and iterative calculation) and the display options for this workbook (sheet
|
||||
/// tabs and scroll bars) when a document is active. Theme, language, formula
|
||||
/// bar, AutoComplete and calculation changes run their commands through `run_or_alert`; calculation
|
||||
/// settings are saved with the workbook.
|
||||
pub(super) fn options(app: &mut SheetApp, ui: &mut Ui) {
|
||||
@@ -14,6 +15,10 @@ pub(super) fn options(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() {
|
||||
app.run_or_alert("view.formulaBar", json!({"on": formula_bar}));
|
||||
}
|
||||
let mut highlight_row = app.ui.highlight_active_row;
|
||||
if ui.checkbox(&mut highlight_row, lang.tr("Highlight the active cell's row")).changed() {
|
||||
app.run_or_alert("options.highlightActiveRow", json!({"on": highlight_row}));
|
||||
}
|
||||
ui.add_space(18.0);
|
||||
section(ui, lang, "Options|Editing");
|
||||
let mut autocomplete = app.ui.autocomplete;
|
||||
@@ -26,7 +31,20 @@ pub(super) fn options(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() {
|
||||
app.toggle_autosave();
|
||||
}
|
||||
let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return };
|
||||
let Some((calc, view)) = app.session.active().map(|d| (d.wb.calc.clone(), d.wb.view)) else { return };
|
||||
ui.add_space(18.0);
|
||||
// Excel's Advanced › Display options for this workbook: saved with the workbook (#3101).
|
||||
section(ui, lang, "Display options for this workbook");
|
||||
for (on, label, id) in [
|
||||
(view.show_horizontal_scroll, "Show horizontal scroll bar", "view.showHorizontalScrollBar"),
|
||||
(view.show_vertical_scroll, "Show vertical scroll bar", "view.showVerticalScrollBar"),
|
||||
(view.show_sheet_tabs, "Show sheet tabs", "view.showSheetTabs"),
|
||||
] {
|
||||
let mut on = on;
|
||||
if ui.checkbox(&mut on, lang.tr(label)).changed() {
|
||||
app.run_or_alert(id, json!({"on": on}));
|
||||
}
|
||||
}
|
||||
ui.add_space(18.0);
|
||||
section(ui, lang, "Formulas");
|
||||
ui.label(lang.tr("Workbook calculation"));
|
||||
|
||||
@@ -316,11 +316,12 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
|
||||
p.add(PathShape::convex_polygon(vec![pos2(r.center().x, r.top()), r.right_bottom(), r.left_bottom()], fill, Stroke::new(1.0, line)));
|
||||
}
|
||||
ShapeKind::Ink => {
|
||||
let w = gridcraft_engine::cmd::draw::ink_width(sp) * geo.z;
|
||||
let pt = gridcraft_engine::cmd::draw::ink_width(sp);
|
||||
let w = pt * geo.z;
|
||||
let pts: Vec<Pos2> =
|
||||
gridcraft_engine::cmd::draw::ink_points(sp).iter().map(|q| pos2(r.left() + q[0] * geo.z, r.top() + q[1] * geo.z)).collect();
|
||||
if pts.len() >= 2 {
|
||||
let col = if w > 8.0 { line.gamma_multiply(0.45) } else { line };
|
||||
let col = ink_color(line, pt);
|
||||
p.add(PathShape::line(pts, PathStroke::new(w, col)));
|
||||
}
|
||||
label = false;
|
||||
@@ -556,3 +557,9 @@ pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Re
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The colour ink is painted in: a highlighter (a stroke over 8 pt wide) is translucent, by its
|
||||
/// width in points, so the live pen stroke and the committed ink agree at every zoom.
|
||||
pub(crate) fn ink_color(line: Color32, width_pt: f32) -> Color32 {
|
||||
if width_pt > 8.0 { line.gamma_multiply(0.45) } else { line }
|
||||
}
|
||||
|
||||
@@ -263,12 +263,17 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
||||
("ui.ribbonTab", "Ribbon Tab", ""),
|
||||
("app.checkForUpdates", "Check for Updates…", ""),
|
||||
("view.collapseRibbon", "Collapse Ribbon", ""),
|
||||
("view.expandFormulaBar", "Expand Formula Bar", "{on?: bool} (toggles without it) → {on}"),
|
||||
(
|
||||
"view.expandFormulaBar",
|
||||
"Expand Formula Bar",
|
||||
"{on?: bool} (toggles without it) → {on}; {lines: int} sets the expanded height (1 collapses) → {on, lines}",
|
||||
),
|
||||
("ui.pythonMode", "Python Mode", "{on?: bool} (toggles without it) → {on}; a refused start is an error"),
|
||||
("view.darkMode", "Dark Mode", ""),
|
||||
("view.theme", "Display Theme", ""),
|
||||
("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"),
|
||||
("options.autoComplete", "Enable AutoComplete for cell values", "{on?: bool} (toggles without it) → {on}"),
|
||||
("options.highlightActiveRow", "Highlight the active cell's row", "{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|account|close}"),
|
||||
|
||||
@@ -564,6 +564,8 @@ impl Dialog {
|
||||
d.values.insert("text".into(), json!(text));
|
||||
d.values.insert("tooltip".into(), json!(link.tooltip));
|
||||
d.original_link_text = Some(text);
|
||||
// As in Excel, the dialog on a linked cell edits that link.
|
||||
d.title = "Edit Hyperlink".into();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
@@ -40,7 +40,21 @@ pub(super) fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
d.values.insert("matchCase".into(), json!(case));
|
||||
d.values.insert("wholeCell".into(), json!(whole));
|
||||
d.values.insert("within".into(), json!(if wb { "workbook" } else { "sheet" }));
|
||||
let base = json!({"what": what, "matchCase": case, "wholeCell": whole, "within": if wb { "workbook" } else { "sheet" }});
|
||||
// As in Excel, a selected range (more than one cell) limits the search on the sheet to it.
|
||||
// One selected cell, merged or not, searches the sheet, as in Excel.
|
||||
let range_selected = app.session.active().is_some_and(|doc| {
|
||||
let sel = &doc.selection;
|
||||
let one_merged = sel.ranges.len() == 1 && doc.wb.active().and_then(|sh| sh.merge_at(sel.current().start)).is_some_and(|m| m == sel.current());
|
||||
!sel.is_single_cell() && !one_merged
|
||||
});
|
||||
let within = if wb {
|
||||
"workbook"
|
||||
} else if range_selected {
|
||||
"selection"
|
||||
} else {
|
||||
"sheet"
|
||||
};
|
||||
let base = json!({"what": what, "matchCase": case, "wholeCell": whole, "within": within});
|
||||
ui.horizontal(|ui| {
|
||||
if d.tab == "Replace" {
|
||||
if ui.button("Replace All").clicked() {
|
||||
|
||||
@@ -5,6 +5,8 @@ use super::{Dialog, Field, Json, Key, SheetApp, Stroke, json, theme, vec2};
|
||||
/// come from the dialog's `fields`, so the letter shortcuts and the control channel see the same.
|
||||
pub(super) fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
let lang = app.ui.language;
|
||||
// L, the Paste Link button's access key, pastes at once as the button does.
|
||||
let link_now = d.values.remove(PASTE_LINK_NOW).is_some_and(|v| v == json!(true));
|
||||
let t = theme::Tokens::get(ui.ctx());
|
||||
type Choice = (&'static str, &'static str, Vec<(&'static str, &'static str)>);
|
||||
let choices: Vec<Choice> = d
|
||||
@@ -56,7 +58,7 @@ pub(super) fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialo
|
||||
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() {
|
||||
if ui.button(lang.tr("Paste Link")).on_hover_text(lang.tr("Paste formulas that refer to the copied cells")).clicked() || link_now {
|
||||
d.values.insert("what".into(), json!("all"));
|
||||
d.values.insert("operation".into(), json!("none"));
|
||||
d.values.insert("link".into(), json!(true));
|
||||
@@ -74,6 +76,45 @@ pub(super) fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialo
|
||||
});
|
||||
}
|
||||
|
||||
/// Set by the Paste Link access key for the next frame of the dialog, which then pastes.
|
||||
const PASTE_LINK_NOW: &str = "pasteLinkNow";
|
||||
|
||||
/// What one of Paste Special's access keys does: choose an option of a group, or flip a box.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Mnemonic {
|
||||
Set(&'static str, &'static str),
|
||||
Toggle(&'static str),
|
||||
/// The Paste Link button: pastes at once.
|
||||
PasteLink,
|
||||
}
|
||||
|
||||
/// Excel's access keys in Paste Special (#1482): the Paste choices, the operations, the two check
|
||||
/// boxes and the Paste Link button, each the letter Excel underlines.
|
||||
fn mnemonic_action(key: Key) -> Option<(char, Mnemonic)> {
|
||||
use Mnemonic::{Set, Toggle};
|
||||
Some(match key {
|
||||
Key::A => ('a', Set("what", "all")),
|
||||
Key::F => ('f', Set("what", "formulas")),
|
||||
Key::V => ('v', Set("what", "values")),
|
||||
Key::T => ('t', Set("what", "formats")),
|
||||
Key::C => ('c', Set("what", "comments")),
|
||||
Key::N => ('n', Set("what", "validation")),
|
||||
Key::X => ('x', Set("what", "allExceptBorders")),
|
||||
Key::W => ('w', Set("what", "columnWidths")),
|
||||
Key::R => ('r', Set("what", "formulasAndNumberFormats")),
|
||||
Key::U => ('u', Set("what", "valuesAndNumberFormats")),
|
||||
Key::O => ('o', Set("operation", "none")),
|
||||
Key::D => ('d', Set("operation", "add")),
|
||||
Key::S => ('s', Set("operation", "subtract")),
|
||||
Key::M => ('m', Set("operation", "multiply")),
|
||||
Key::I => ('i', Set("operation", "divide")),
|
||||
Key::B => ('b', Toggle("skipBlanks")),
|
||||
Key::E => ('e', Toggle("transpose")),
|
||||
Key::L => ('l', Mnemonic::PasteLink),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Paste Special's legacy mnemonics select options; Enter still performs the paste.
|
||||
pub(super) fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) {
|
||||
if ctx.text_edit_focused() {
|
||||
@@ -88,14 +129,7 @@ pub(super) fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) {
|
||||
});
|
||||
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
|
||||
let allowed = |m: egui::Modifiers| !m.ctrl && !m.command && !m.mac_cmd && !(m.alt && (mac || right_alt));
|
||||
let mnemonic = |key| match key {
|
||||
Key::T => Some('t'),
|
||||
Key::V => Some('v'),
|
||||
Key::F => Some('f'),
|
||||
Key::C => Some('c'),
|
||||
Key::E => Some('e'),
|
||||
_ => None,
|
||||
};
|
||||
let mnemonic = |key| mnemonic_action(key).map(|(letter, _)| letter);
|
||||
let mut text_to_consume = Vec::new();
|
||||
for event in ctx.input(|i| i.events.clone()) {
|
||||
let egui::Event::Key { key, pressed: true, repeat, modifiers, .. } = event else { continue };
|
||||
@@ -107,17 +141,18 @@ pub(super) fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) {
|
||||
if repeat {
|
||||
continue;
|
||||
}
|
||||
if key == Key::E {
|
||||
let transpose = d.values.get("transpose").and_then(Json::as_bool).unwrap_or(false);
|
||||
d.values.insert("transpose".into(), json!(!transpose));
|
||||
} else {
|
||||
let what = match key {
|
||||
Key::T => "formats",
|
||||
Key::V => "values",
|
||||
Key::F => "formulas",
|
||||
_ => "comments",
|
||||
};
|
||||
d.values.insert("what".into(), json!(what));
|
||||
match mnemonic_action(key).map(|(_, action)| action) {
|
||||
Some(Mnemonic::Set(field, value)) => {
|
||||
d.values.insert(field.into(), json!(value));
|
||||
}
|
||||
Some(Mnemonic::Toggle(field)) => {
|
||||
let on = d.values.get(field).and_then(Json::as_bool).unwrap_or(false);
|
||||
d.values.insert(field.into(), json!(!on));
|
||||
}
|
||||
Some(Mnemonic::PasteLink) => {
|
||||
d.values.insert(PASTE_LINK_NOW.into(), json!(true));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// Browsers can deliver Text before or after Key. Act only on Key, then remove
|
||||
|
||||
@@ -12,6 +12,14 @@ use crate::theme::{self, Tokens};
|
||||
|
||||
/// Lines the formula bar (and the in-cell editor) grows to before it scrolls.
|
||||
pub const MAX_LINES: usize = 5;
|
||||
/// The most lines a drag of the bar's bottom edge can give it (the window caps it at half its
|
||||
/// height first).
|
||||
pub const MAX_DRAG_LINES: usize = 100;
|
||||
/// How far the bar's bottom edge reaches up into the bar as a resize handle (its bottom margin).
|
||||
const HANDLE: f32 = 4.0;
|
||||
/// What an expanded bar leaves below it: the status bar (26) and sheet tabs (32), and the
|
||||
/// worksheet's column headings and two rows, so the grid never disappears in a short window.
|
||||
const KEEP_BELOW: f32 = 26.0 + 32.0 + crate::grid::COL_HEADER_H + 2.0 * gridcraft_engine::model::DEFAULT_ROW_HEIGHT;
|
||||
/// The bar's height at one line.
|
||||
const ONE_LINE: f32 = 30.0;
|
||||
/// What the bar adds around the field's rows: the panel's margins (3 + 3), the field's (3 + 3)
|
||||
@@ -40,11 +48,22 @@ pub struct BarLayout {
|
||||
pub visible_lines: usize,
|
||||
/// The caret while editing in the bar.
|
||||
pub caret: Option<Rect>,
|
||||
/// The bottom edge that resizes the bar.
|
||||
pub handle: Rect,
|
||||
}
|
||||
|
||||
impl Default for BarLayout {
|
||||
fn default() -> Self {
|
||||
BarLayout { name_box: Rect::NOTHING, buttons: Rect::NOTHING, field: Rect::NOTHING, height: 0.0, lines: 1, visible_lines: 1, caret: None }
|
||||
BarLayout {
|
||||
name_box: Rect::NOTHING,
|
||||
buttons: Rect::NOTHING,
|
||||
field: Rect::NOTHING,
|
||||
height: 0.0,
|
||||
lines: 1,
|
||||
visible_lines: 1,
|
||||
caret: None,
|
||||
handle: Rect::NOTHING,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,13 +92,15 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
let font = theme::ui_font(13.0);
|
||||
let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font));
|
||||
let last = BarLayout::get(ui.ctx());
|
||||
// Height follows the content's line count (last frame's), 1 to MAX_LINES; the chevron pins it
|
||||
// at MAX_LINES. It eases only when that target changes.
|
||||
let lines = if app.ui.formula_bar_expanded { MAX_LINES } else { last.lines.clamp(1, MAX_LINES) };
|
||||
// Height follows the content's line count (last frame's), 1 to MAX_LINES; expanded (the
|
||||
// chevron), it is the height the bottom edge was dragged to. It eases only when that target
|
||||
// changes.
|
||||
let most = max_lines(ui.ctx().content_rect().height(), ui.available_rect_before_wrap().height(), row_h);
|
||||
let lines = if app.ui.formula_bar_expanded { app.ui.formula_bar_lines.clamp(1, most) } else { last.lines.clamp(1, MAX_LINES) };
|
||||
let target = one_line(row_h) + (lines - 1) as f32 * row_h;
|
||||
let h = ui.ctx().animate_value_with_time(egui::Id::unique("formula_bar.height"), target, EASE_SECONDS);
|
||||
let mut layout = BarLayout { height: h, ..Default::default() };
|
||||
egui::Panel::top("formula_bar")
|
||||
let panel = egui::Panel::top("formula_bar")
|
||||
.exact_size(h)
|
||||
.frame(egui::Frame::NONE.fill(t.window).inner_margin(egui::Margin { left: 8, right: 8, top: 3, bottom: 3 }))
|
||||
.show(ui, |ui| {
|
||||
@@ -156,9 +177,62 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
});
|
||||
});
|
||||
layout.handle = resize_handle(app, ui, panel.response.rect, row_h, most);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(BarLayout::id(), layout));
|
||||
}
|
||||
|
||||
/// The most lines the bar takes in a window `window_h` tall with `room` left under the ribbon:
|
||||
/// half the window, as in Excel (never fewer than the chevron's [`MAX_LINES`]), and no more than
|
||||
/// leaves [`KEEP_BELOW`] for the status bar, sheet tabs and a few rows of the grid.
|
||||
fn max_lines(window_h: f32, room: f32, row_h: f32) -> usize {
|
||||
let lines_in = |h: f32| {
|
||||
let fit = ((h - one_line(row_h)) / row_h).floor();
|
||||
if fit.is_finite() && fit > 0.0 { fit as usize + 1 } else { 1 }
|
||||
};
|
||||
lines_in(window_h * 0.5).clamp(MAX_LINES, MAX_DRAG_LINES).min(lines_in(room - KEEP_BELOW))
|
||||
}
|
||||
|
||||
/// The lines a bar whose top is at `top` shows with its bottom edge dragged to `y`, in whole lines.
|
||||
fn dragged_lines(y: f32, top: f32, row_h: f32, most: usize) -> usize {
|
||||
let extra = ((y - top - one_line(row_h)) / row_h).round();
|
||||
if extra.is_finite() && extra > 0.0 { (extra as usize + 1).min(most) } else { 1 }
|
||||
}
|
||||
|
||||
/// The bar's bottom edge: dragging it sets the bar's height in whole lines, as in Excel. The bar
|
||||
/// follows the pointer; the release runs `view.expandFormulaBar` with the lines, so one drag is
|
||||
/// one step (one line collapses the bar).
|
||||
fn resize_handle(app: &mut SheetApp, ui: &mut egui::Ui, bar: Rect, row_h: f32, most: usize) -> Rect {
|
||||
let handle = Rect::from_x_y_ranges(bar.x_range(), (bar.bottom() - HANDLE)..=bar.bottom());
|
||||
let resp = ui.interact(handle, egui::Id::unique("formula_bar.resize"), Sense::drag());
|
||||
let name = app.ui.language.tr("Resize formula bar");
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name));
|
||||
if resp.hovered() || resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
|
||||
}
|
||||
// The state before the drag: the preview changes it, the release puts it back for the command.
|
||||
let before = resp.id.with("before");
|
||||
if resp.drag_started() {
|
||||
let state = (app.ui.formula_bar_expanded, app.ui.formula_bar_lines);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(before, state));
|
||||
}
|
||||
if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.dragged() || resp.drag_stopped()) {
|
||||
let lines = dragged_lines(p.y, bar.top(), row_h, most);
|
||||
if resp.drag_stopped() {
|
||||
if let Some((expanded, kept)) = ui.ctx().data_mut(|d| d.remove_temp::<(bool, usize)>(before)) {
|
||||
(app.ui.formula_bar_expanded, app.ui.formula_bar_lines) = (expanded, kept);
|
||||
}
|
||||
app.run_or_alert("view.expandFormulaBar", json!({"lines": lines}));
|
||||
} else {
|
||||
app.ui.formula_bar_expanded = lines > 1;
|
||||
if lines > 1 {
|
||||
app.ui.formula_bar_lines = lines;
|
||||
}
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
handle
|
||||
}
|
||||
|
||||
/// Resolve ownership on every frame; never cache a spill range in presentation state.
|
||||
pub(crate) fn active_spill_anchor(app: &SheetApp) -> Option<gridcraft_engine::core::CellRef> {
|
||||
let d = app.session.active()?;
|
||||
|
||||
@@ -22,6 +22,8 @@ type PagesCache = ((u64, u64, usize), Vec<(RangeRef, u32)>);
|
||||
/// Cached Show Details cells, keyed by (doc uid, revision, sheet, pivot).
|
||||
type DetailsCache = ((u64, u64, usize, usize), Option<std::collections::HashSet<(u32, u32)>>);
|
||||
|
||||
mod active_row;
|
||||
pub use active_row::active_row_band;
|
||||
mod cell_paint;
|
||||
mod cursor;
|
||||
mod editor;
|
||||
@@ -101,6 +103,10 @@ pub enum Drag {
|
||||
Select,
|
||||
Rows(u32),
|
||||
Cols(u32),
|
||||
/// Point mode over the row headers: the formula's reference runs from this row to the pointer.
|
||||
PointRows(u32),
|
||||
/// Point mode over the column headers: the formula's reference runs from this column to the pointer.
|
||||
PointCols(u32),
|
||||
/// Reordering one contiguous whole-column selection by its header.
|
||||
MoveCols {
|
||||
source: RangeRef,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//! "Highlight the active cell's row" (File ▸ Options ▸ General): a tint across the whole row of
|
||||
//! the active cell, so the eye keeps its place in a wide table.
|
||||
|
||||
use super::geo::Geo;
|
||||
use super::quadrant::QuadrantPaint;
|
||||
use super::{Rect, Sheet, pos2};
|
||||
|
||||
/// The tinted band of `row` inside one quadrant (`rows` x `cols`): none when the row is outside
|
||||
/// the quadrant or hidden.
|
||||
pub fn active_row_band(geo: &Geo, sh: &Sheet, row: u32, rows: (u32, u32), cols: (u32, u32)) -> Option<Rect> {
|
||||
let height = sh.row_height(row) * geo.z;
|
||||
if row < rows.0 || row > rows.1 || height <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let top = geo.y(sh, row);
|
||||
let right = geo.x(sh, cols.1) + sh.col_width(cols.1) * geo.z;
|
||||
Some(Rect::from_min_max(pos2(geo.x(sh, cols.0), top), pos2(right, top + height)))
|
||||
}
|
||||
|
||||
/// Paints the band of one quadrant, when the preference is on. With `within`, only the part of it
|
||||
/// inside that area (text running past its cell has just cleared it).
|
||||
pub(super) fn paint_active_row(q: &QuadrantPaint<'_>, within: Option<Rect>) {
|
||||
let Some(band) = q.active_row.and_then(|row| active_row_band(q.geo, q.sheet, row, q.rows, q.cols)) else { return };
|
||||
let part = within.map_or(band, |area| band.intersect(area));
|
||||
if part.is_positive() {
|
||||
q.painter.rect_filled(part, 0.0, q.tokens.active_row);
|
||||
}
|
||||
}
|
||||
@@ -194,7 +194,7 @@ pub(super) fn merged_edges(wb: &Workbook, si: usize, sh: &Sheet, geo: &Geo, area
|
||||
if sh.col_width(col) == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let (x0, x1) = (geo.x(sh, col), geo.x(sh, col.saturating_add(1)));
|
||||
let (x0, x1) = (geo.x(sh, col), geo.x_end(sh, col));
|
||||
let top = look(wb, si, sh, CellRef::new(m.start.row, col), cf).0.style.borders.top;
|
||||
let bottom = look(wb, si, sh, CellRef::new(m.end.row, col), cf).0.style.borders.bottom;
|
||||
out.push((top, [pos2(x0, rect.top()), pos2(x1, rect.top())]));
|
||||
@@ -205,7 +205,7 @@ pub(super) fn merged_edges(wb: &Workbook, si: usize, sh: &Sheet, geo: &Geo, area
|
||||
if sh.row_height(row) == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let (y0, y1) = (geo.y(sh, row), geo.y(sh, row.saturating_add(1)));
|
||||
let (y0, y1) = (geo.y(sh, row), geo.y_end(sh, row));
|
||||
let left = look(wb, si, sh, CellRef::new(row, m.start.col), cf).0.style.borders.left;
|
||||
let right = look(wb, si, sh, CellRef::new(row, m.end.col), cf).0.style.borders.right;
|
||||
out.push((left, [pos2(rect.left(), y0), pos2(rect.left(), y1)]));
|
||||
|
||||
@@ -48,7 +48,7 @@ fn drag_cursor(drag: &Drag, rtl: bool) -> Option<CursorIcon> {
|
||||
match drag {
|
||||
Drag::None => None,
|
||||
Drag::Select => Some(CursorIcon::Cell),
|
||||
Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default),
|
||||
Drag::Rows(_) | Drag::Cols(_) | Drag::PointRows(_) | Drag::PointCols(_) => Some(CursorIcon::Default),
|
||||
Drag::Fill { .. } => Some(CursorIcon::Crosshair),
|
||||
Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { handle: None, .. } => Some(CursorIcon::Move),
|
||||
Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn),
|
||||
|
||||
@@ -125,7 +125,7 @@ pub(super) fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_
|
||||
app.grid.list_picker = None;
|
||||
return;
|
||||
};
|
||||
let items = gridcraft_engine::cmd::data::list_items(&wb, si, &dv);
|
||||
let items = gridcraft_engine::cmd::data::list_items(&wb, si, c, &dv);
|
||||
let r = geo.cell_rect(sh, c);
|
||||
// Fit the popup to its rows (up to LIST_PICKER_MAX_ROWS); without a minimum the scroll area collapses to its 64 px default.
|
||||
let height = list_picker_height(items.len(), LIST_PICKER_ROW_H, ui.spacing().item_spacing.y);
|
||||
|
||||
@@ -158,11 +158,21 @@ impl Geo {
|
||||
self.cells.top() + self.frozen_h + (self.ri.start(row) as f32 - self.ri.start(self.fr) as f32 - self.scroll.y) * self.z
|
||||
}
|
||||
}
|
||||
/// Screen x of `col`'s right edge. Not `x(col + 1)`: across the freeze line (and between
|
||||
/// pages) the next column is laid out elsewhere (#2544).
|
||||
pub fn x_end(&self, sh: &Sheet, col: u32) -> f32 {
|
||||
self.x(sh, col) + sh.col_width(col) * self.z
|
||||
}
|
||||
/// Screen y of `row`'s bottom edge (see `x_end`).
|
||||
pub fn y_end(&self, sh: &Sheet, row: u32) -> f32 {
|
||||
self.y(sh, row) + sh.row_height(row) * self.z
|
||||
}
|
||||
/// Columns `a` and `b` are on the same side of the freeze line, so they scroll together.
|
||||
pub fn same_col_pane(&self, a: u32, b: u32) -> bool {
|
||||
(a < self.fc) == (b < self.fc)
|
||||
}
|
||||
pub fn cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect {
|
||||
Rect::from_min_max(
|
||||
pos2(self.x(sh, c.col), self.y(sh, c.row)),
|
||||
pos2(self.x(sh, c.col) + sh.col_width(c.col) * self.z, self.y(sh, c.row) + sh.row_height(c.row) * self.z),
|
||||
)
|
||||
Rect::from_min_max(pos2(self.x(sh, c.col), self.y(sh, c.row)), pos2(self.x_end(sh, c.col), self.y_end(sh, c.row)))
|
||||
}
|
||||
pub fn range_rect(&self, sh: &Sheet, r: RangeRef) -> Rect {
|
||||
let a = self.cell_rect(sh, r.start);
|
||||
|
||||
@@ -225,10 +225,10 @@ fn handle_draw_tool(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool {
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
input
|
||||
.ui
|
||||
.painter()
|
||||
.add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(2.0 * geo.z, Color32::from_rgb(0x1F, 0x5F, 0xC9))));
|
||||
// The line under the pointer is the ink the stroke will commit (#1786).
|
||||
let theme = app.session.active().map(|d| d.wb.theme.clone()).unwrap_or_default();
|
||||
let (color, width) = pen_ink(&app.session.draw_color, app.session.draw_width, &theme);
|
||||
input.ui.painter().add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(width * geo.z, color)));
|
||||
}
|
||||
if resp.drag_stopped() && app.grid.ink.len() >= 2 {
|
||||
let pts: Vec<[f32; 2]> = std::mem::take(&mut app.grid.ink);
|
||||
@@ -249,6 +249,17 @@ fn handle_draw_tool(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The colour and width (points) of the ink a pen stroke commits, as `draw.stroke` stores it (the
|
||||
/// pen's colour, pen blue by default; its width, 2 pt by default, kept to 0.5–40 pt and stored
|
||||
/// whole) and as Ink shapes paint it (a highlighter, over 8 pt, is translucent).
|
||||
pub(crate) fn pen_ink(color: &str, width: f32, theme: &gridcraft_engine::model::Theme) -> (Color32, f32) {
|
||||
let pen_blue = gridcraft_engine::model::Color::rgb(0x1F, 0x5F, 0xC9);
|
||||
let line = gridcraft_engine::model::Color::from_hex(color).unwrap_or(pen_blue);
|
||||
let width = if width <= 0.0 { 2.0 } else { width }.clamp(0.5, 40.0).round().max(1.0);
|
||||
let color = line.resolve(theme).map(crate::theme::color32).unwrap_or(Color32::from_rgb(0x1F, 0x5F, 0xC9));
|
||||
(crate::chartview::ink_color(color, width), width)
|
||||
}
|
||||
|
||||
fn handle_header_autofit(app: &mut SheetApp, input: &InteractionInput<'_>) -> bool {
|
||||
let (geo, sh, sel, pos) = (input.geo, input.sheet, &input.selection, input.position);
|
||||
let (in_col_header, in_row_header) = (input.in_column_header, input.in_row_header);
|
||||
@@ -307,6 +318,7 @@ fn handle_column_header_press(app: &mut SheetApp, input: &InteractionInput<'_>,
|
||||
return;
|
||||
}
|
||||
if point_header(app, wb, sh, RangeRef::cols(c, c), mods.shift, mods.command) {
|
||||
point_header_first_move(app, input);
|
||||
return;
|
||||
}
|
||||
// An armed Format Painter paints the columns dragged over instead of moving them.
|
||||
@@ -337,6 +349,7 @@ fn handle_row_header_press(app: &mut SheetApp, input: &InteractionInput<'_>, p:
|
||||
}
|
||||
let r = geo.row_at(sh, p.y);
|
||||
if point_header(app, wb, sh, RangeRef::rows(r, r), mods.shift, mods.command) {
|
||||
point_header_first_move(app, input);
|
||||
return;
|
||||
}
|
||||
let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) };
|
||||
@@ -612,6 +625,10 @@ fn handle_dragging(app: &mut SheetApp, input: &InteractionInput<'_>) {
|
||||
app.session.run("selection.set", json!({"range": RangeRef::cols(anchor.min(c), anchor.max(c)).a1(), "active": sel.active.a1()}));
|
||||
scroll_by(app, vec2(auto.x, 0.0));
|
||||
}
|
||||
drag @ (Drag::PointRows(_) | Drag::PointCols(_)) => {
|
||||
point_header_to(app, geo, sh, wb, &drag, p);
|
||||
scroll_by(app, if matches!(drag, Drag::PointRows(_)) { vec2(0.0, auto.y) } else { vec2(auto.x, 0.0) });
|
||||
}
|
||||
Drag::MoveCols { source, .. } => {
|
||||
let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0));
|
||||
let midpoint = geo.x(sh, col) + sh.col_width(col) * geo.z * 0.5;
|
||||
@@ -688,6 +705,14 @@ fn handle_release(app: &mut SheetApp, input: &InteractionInput<'_>) {
|
||||
}
|
||||
// Whole rows or columns clicked or dragged over by their headers.
|
||||
Drag::Rows(_) | Drag::Cols(_) => format_painter_apply(app),
|
||||
// The release may be the first frame to report the pointer's last position.
|
||||
drag @ (Drag::PointRows(_) | Drag::PointCols(_)) => {
|
||||
if resp.drag_stopped()
|
||||
&& let Some(p) = pos
|
||||
{
|
||||
point_header_to(app, geo, sh, wb, &drag, p);
|
||||
}
|
||||
}
|
||||
// Esc mid-drag lands here too (egui ends the drag on Esc): no menu, and the key is
|
||||
// spent on the drag so the grid's Esc doesn't also drop the copy.
|
||||
Drag::Fill { target: Some(t), menu: true } => {
|
||||
@@ -719,9 +744,9 @@ fn handle_release(app: &mut SheetApp, input: &InteractionInput<'_>) {
|
||||
}
|
||||
Drag::Move { to: Some(to), .. } => {
|
||||
let src = sel.current();
|
||||
// Dropping on cells that hold data asks first, as in Excel.
|
||||
if to != src.start {
|
||||
let _ = app.session.run("edit.cut", json!({"range": src.a1()}));
|
||||
let _ = app.run("edit.paste", json!({"at": to.a1()}));
|
||||
crate::macro_ui::call(app, "GridCraft", "range.move", json!({"range": src.a1(), "to": to.a1()}));
|
||||
}
|
||||
}
|
||||
Drag::MoveCols { source, before: Some(before) } => {
|
||||
@@ -751,11 +776,13 @@ fn format_painter_apply(app: &mut SheetApp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Point mode on a header: inserts a whole-row or whole-column reference instead of selecting.
|
||||
/// Point mode on a header: inserts a whole-row or whole-column reference instead of selecting,
|
||||
/// and dragging on from there extends it.
|
||||
fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, extend: bool, add: bool) -> bool {
|
||||
let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false };
|
||||
let edit_sheet = ed.sheet;
|
||||
let active_sheet = wb.active_sheet();
|
||||
let pressed = range;
|
||||
let range = if extend {
|
||||
ed.point
|
||||
.and_then(|point| crate::editor::formula_refs(&ed.text).into_iter().find(|(start, end, ..)| (*start, *end) == point))
|
||||
@@ -776,9 +803,7 @@ fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef,
|
||||
} else {
|
||||
range
|
||||
};
|
||||
let reference = range.a1();
|
||||
let reference =
|
||||
if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference };
|
||||
let reference = header_reference(ed, wb, sh, range);
|
||||
if add {
|
||||
ed.append_ref(&reference);
|
||||
} else {
|
||||
@@ -787,9 +812,53 @@ fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef,
|
||||
ed.point_anchor = None;
|
||||
ed.point_cell = Some(range.start);
|
||||
ed.request_focus = true;
|
||||
// The drag keeps the end of the reference the press didn't land on.
|
||||
app.grid.drag = if range.is_full_cols() {
|
||||
Drag::PointCols(if range.start.col == pressed.start.col { range.end.col } else { range.start.col })
|
||||
} else {
|
||||
Drag::PointRows(if range.start.row == pressed.start.row { range.end.row } else { range.start.row })
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
fn header_reference(ed: &EditState, wb: &Workbook, sh: &Sheet, range: RangeRef) -> String {
|
||||
let reference = range.a1();
|
||||
if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference }
|
||||
}
|
||||
|
||||
/// The frame that starts a drag reports no `dragged()`, so its movement is applied here.
|
||||
fn point_header_first_move(app: &mut SheetApp, input: &InteractionInput<'_>) {
|
||||
if input.response.drag_started()
|
||||
&& let Some(p) = input.position
|
||||
{
|
||||
let drag = app.grid.drag.clone();
|
||||
point_header_to(app, input.geo, input.sheet, input.workbook, &drag, p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Point mode dragged over the headers: the pointed reference runs from the anchor to the pointer.
|
||||
fn point_header_to(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, drag: &Drag, p: Pos2) {
|
||||
// The pointer's end moves with Shift+arrow next; the opposite corner keeps it a whole column or row.
|
||||
let (range, pointer, opposite) = match *drag {
|
||||
Drag::PointCols(anchor) => {
|
||||
let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0));
|
||||
let range = RangeRef::cols(anchor.min(col), anchor.max(col));
|
||||
(range, CellRef::new(range.start.row, col), CellRef::new(range.end.row, anchor))
|
||||
}
|
||||
Drag::PointRows(anchor) => {
|
||||
let row = geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0));
|
||||
let range = RangeRef::rows(anchor.min(row), anchor.max(row));
|
||||
(range, CellRef::new(row, range.start.col), CellRef::new(anchor, range.end.col))
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
let Some(ed) = app.editor.as_mut().filter(|ed| ed.point_at_caret()) else { return };
|
||||
let reference = header_reference(ed, wb, sh, range);
|
||||
ed.insert_ref(&reference);
|
||||
ed.point_cell = Some(pointer);
|
||||
ed.point_anchor = Some((wb.active_sheet(), opposite));
|
||||
}
|
||||
|
||||
fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) {
|
||||
let cell = CellRef::new(
|
||||
geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)),
|
||||
@@ -891,7 +960,7 @@ fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 {
|
||||
return 0;
|
||||
}
|
||||
let c = geo.col_at(sh, p.x);
|
||||
let (l, r) = (geo.x(sh, c), geo.x(sh, c.saturating_add(1).min(MAX_COLS - 1)));
|
||||
let (l, r) = (geo.x(sh, c), geo.x_end(sh, c));
|
||||
let b = if p.x - l > r - p.x { c.saturating_add(1) } else { c };
|
||||
let room = geo.ci.at((((geo.cells.width() - 40.0) / geo.z).max(0.0)) as f64);
|
||||
b.min(room).min(MAX_COLS - 1)
|
||||
@@ -900,7 +969,7 @@ fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 {
|
||||
return 0;
|
||||
}
|
||||
let rr = geo.row_at(sh, p.y);
|
||||
let (t, b2) = (geo.y(sh, rr), geo.y(sh, rr.saturating_add(1).min(MAX_ROWS - 1)));
|
||||
let (t, b2) = (geo.y(sh, rr), geo.y_end(sh, rr));
|
||||
let b = if p.y - t > b2 - p.y { rr.saturating_add(1) } else { rr };
|
||||
let room = geo.ri.at((((geo.cells.height() - 40.0) / geo.z).max(0.0)) as f64);
|
||||
b.min(room).min(MAX_ROWS - 1)
|
||||
@@ -1038,9 +1107,27 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte
|
||||
to
|
||||
};
|
||||
let text = if extend { RangeRef::new(anchor, to).a1() } else { to.a1() };
|
||||
let text = if ed.sheet != d.wb.active_sheet() { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sheet.name)) } else { text };
|
||||
let sheet = d.wb.active_sheet();
|
||||
ed.insert_ref(&text);
|
||||
ed.point_cell = Some(to);
|
||||
ed.point_anchor = Some((sheet, anchor));
|
||||
app.grid.ensure_visible = true;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_pen_preview_is_the_ink_the_stroke_commits() {
|
||||
// #1786: the preview was always blue at 2 pt, whatever the pen.
|
||||
let theme = gridcraft_engine::model::Theme::default();
|
||||
assert_eq!(pen_ink("#C00000", 3.0, &theme), (Color32::from_rgb(0xC0, 0, 0), 3.0));
|
||||
assert_eq!(pen_ink("", 0.0, &theme), (Color32::from_rgb(0x1F, 0x5F, 0xC9), 2.0), "no pen chosen yet: pen blue, 2 pt");
|
||||
assert_eq!(pen_ink("not a colour", 0.2, &theme), (Color32::from_rgb(0x1F, 0x5F, 0xC9), 1.0));
|
||||
// A highlighter is wide and translucent, as the committed ink is painted.
|
||||
let (c, w) = pen_ink("#FFFF00", 12.0, &theme);
|
||||
assert_eq!((c, w), (Color32::from_rgb(0xFF, 0xFF, 0).gamma_multiply(0.45), 12.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,11 +51,9 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
|
||||
app.copy_to_clipboard(ctx, "edit.cut");
|
||||
}
|
||||
egui::Event::Paste(text) => app.paste_external(text),
|
||||
// GridCraft has no image-clipboard pipeline and no cell-picture insert-from-clipboard
|
||||
// command. Choosing to ignore `PasteImage` is explicit: inserting the bytes as a
|
||||
// floating picture without a chosen anchor/size would be a new feature, not parity.
|
||||
// A text representation, when the clipboard has one, still arrives as `Paste` above.
|
||||
egui::Event::PasteImage(_) => {}
|
||||
// A picture with no text on the clipboard (a screenshot, a browser image) is pasted as
|
||||
// a floating picture at the active cell, as Excel does.
|
||||
egui::Event::PasteImage(image) => app.paste_color_image(&image),
|
||||
// PRIMARY middle-click paste belongs to egui's TextEdit; the grid has no caret to
|
||||
// insert text at, so a middle click on a cell is deliberately not a paste.
|
||||
egui::Event::MiddleClickPaste { .. } => {}
|
||||
|
||||
@@ -129,6 +129,9 @@ pub(super) fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(200.0);
|
||||
let insert_copied = insert_copied_item(app);
|
||||
// On a cell with a link Excel offers to edit, open or remove it (#2120).
|
||||
let link =
|
||||
app.session.active().and_then(|d| d.wb.active().and_then(|sh| sh.hyperlinks.get(&d.selection.active)).map(|l| l.target.clone()));
|
||||
let mut items: Vec<(&str, &str)> = vec![
|
||||
("Cut", "edit.cut"),
|
||||
("Copy", "edit.copy"),
|
||||
@@ -154,6 +157,14 @@ pub(super) fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
if let Some(item) = insert_copied {
|
||||
items.insert(6, item);
|
||||
}
|
||||
if link.is_some()
|
||||
&& let Some(i) = items.iter().position(|(_, id)| *id == "ui:insertLink")
|
||||
{
|
||||
items.splice(
|
||||
i..=i,
|
||||
[("Edit Hyperlink…", "ui:insertLink"), ("Open Hyperlink", "ui:openLink"), ("Remove Hyperlink", "edit.clearHyperlinks")],
|
||||
);
|
||||
}
|
||||
if show_details_offered(app)
|
||||
&& let Some(i) = items.iter().position(|(_, id)| *id == "data.sortDescending")
|
||||
{
|
||||
@@ -178,6 +189,11 @@ pub(super) fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
"ui:newNote" => app.open_dialog("comment", json!({"threaded": false})),
|
||||
"ui:defineName" => app.open_dialog("defineName", json!({})),
|
||||
"ui:insertLink" => app.open_dialog("insertLink", json!({})),
|
||||
"ui:openLink" => {
|
||||
if let Some(target) = &link {
|
||||
super::interact::follow_link(app, target);
|
||||
}
|
||||
}
|
||||
"ui:pickList" => app.open_dialog("pickList", json!({})),
|
||||
"ui:filterValue" => {
|
||||
if let Some(d) = app.session.active() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::active_row::paint_active_row;
|
||||
use super::cell_paint::{
|
||||
Look, MergedArea, cell_text_y, center_across_end, col32, display_text, look, merged_edges, paint_border, paint_cf_icon, painted_halign,
|
||||
rotated_text_pos, shrink_font, wrapped_cell_text_y,
|
||||
@@ -102,6 +103,8 @@ pub(crate) struct QuadrantPaint<'a> {
|
||||
pub(crate) cols: (u32, u32),
|
||||
pub(crate) cf: &'a mut CfCache,
|
||||
pub(crate) tokens: &'a Tokens,
|
||||
/// The row to tint, when "Highlight the active cell's row" is on.
|
||||
pub(crate) active_row: Option<u32>,
|
||||
}
|
||||
|
||||
/// Paint a worksheet quadrant, including backgrounds, borders, and text.
|
||||
@@ -118,6 +121,8 @@ pub(crate) fn paint_quadrant(mut quadrant: QuadrantPaint<'_>) {
|
||||
let cells = visible_cells(&quadrant);
|
||||
let looks = paint_backgrounds(&mut quadrant, &cells, &merges);
|
||||
let outside = outside_text_cells(&mut quadrant, &merges);
|
||||
// Over the fills and under the text and borders.
|
||||
paint_active_row(&quadrant, None);
|
||||
paint_cells(&mut quadrant, &looks, &outside, &merges);
|
||||
paint_borders(&mut quadrant, &looks, &merges);
|
||||
paint_sparklines(&quadrant, range);
|
||||
@@ -142,21 +147,17 @@ fn paint_gridlines(q: &QuadrantPaint<'_>) {
|
||||
if sh.show_gridlines {
|
||||
let stroke = Stroke::new(1.0, t.gridline);
|
||||
let top = geo.y(sh, r0);
|
||||
let bottom = geo.y(sh, r1 + 1);
|
||||
let bottom = geo.y_end(sh, r1);
|
||||
let left = geo.x(sh, c0);
|
||||
let right = geo.x(sh, c1 + 1);
|
||||
for c in c0..=c1 + 1 {
|
||||
if c <= c1 && sh.col_width(c) == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let x = geo.x(sh, c).round() - 0.5;
|
||||
let right = geo.x_end(sh, c1);
|
||||
let xs = (c0..=c1).filter(|&c| sh.col_width(c) > 0.0).map(|c| geo.x(sh, c)).chain([right]);
|
||||
for x in xs {
|
||||
let x = x.round() - 0.5;
|
||||
p.line_segment([pos2(x, top), pos2(x, bottom)], stroke);
|
||||
}
|
||||
for r in r0..=r1 + 1 {
|
||||
if r <= r1 && sh.row_height(r) == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let y = geo.y(sh, r).round() - 0.5;
|
||||
let ys = (r0..=r1).filter(|&r| sh.row_height(r) > 0.0).map(|r| geo.y(sh, r)).chain([bottom]);
|
||||
for y in ys {
|
||||
let y = y.round() - 0.5;
|
||||
p.line_segment([pos2(left, y), pos2(right, y)], stroke);
|
||||
}
|
||||
}
|
||||
@@ -224,7 +225,6 @@ fn outside_text_cells(q: &mut QuadrantPaint<'_>, merges: &[RangeRef]) -> Vec<(Ce
|
||||
// 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;
|
||||
@@ -232,7 +232,7 @@ fn outside_text_cells(q: &mut QuadrantPaint<'_>, merges: &[RangeRef]) -> Vec<(Ce
|
||||
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() {
|
||||
if geo.same_col_pane(n.col, c0) && 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));
|
||||
}
|
||||
@@ -333,7 +333,9 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
|
||||
let z = geo.z;
|
||||
let (c0, c1) = q.cols;
|
||||
let in_merge = |cell| merges.iter().find(|m| m.contains(cell)).copied();
|
||||
let empty_ok = |cell| sh.value_ref(cell).is_none_or(Value::is_empty) && in_merge(cell).is_none();
|
||||
// Text overflows only into empty cells of its own pane: the frozen and scrolled columns move
|
||||
// apart when scrolling (#2544).
|
||||
let empty_ok = |cell: CellRef| geo.same_col_pane(cell.col, c.col) && sh.value_ref(cell).is_none_or(Value::is_empty) && in_merge(cell).is_none();
|
||||
if paint_cell_decorations(q, c, *rect, lk, cfl.as_ref()) {
|
||||
return;
|
||||
}
|
||||
@@ -454,12 +456,12 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
|
||||
let mut right = rect.right();
|
||||
let mut left = rect.left();
|
||||
if across_end > c.col {
|
||||
right = geo.x(sh, across_end + 1);
|
||||
right = geo.x_end(sh, across_end);
|
||||
}
|
||||
if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) {
|
||||
let mut cc = c.col + 1;
|
||||
while right - rect.left() < w + 2.0 * pad && cc < MAX_COLS && empty_ok(CellRef::new(c.row, cc)) && cc <= c1 + 20 {
|
||||
right = geo.x(sh, cc + 1);
|
||||
right = geo.x_end(sh, cc);
|
||||
cc += 1;
|
||||
}
|
||||
}
|
||||
@@ -478,9 +480,11 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
|
||||
let extra_r = Rect::from_min_max(pos2(rect.right() - 1.0, inner.top()), inner.max);
|
||||
if extra_l.width() > 0.0 {
|
||||
p.rect_filled(extra_l, 0.0, t.grid_bg);
|
||||
paint_active_row(q, Some(extra_l));
|
||||
}
|
||||
if extra_r.width() > 0.0 {
|
||||
p.rect_filled(extra_r, 0.0, t.grid_bg);
|
||||
paint_active_row(q, Some(extra_r));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
let available = ui.available_rect_before_wrap();
|
||||
// A right-to-left sheet has its vertical scrollbar on the left.
|
||||
let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left);
|
||||
let bar = 14.0_f32.min(available.width());
|
||||
let bar = vertical_bar_width(app, available);
|
||||
let rect = if rtl {
|
||||
Rect::from_min_max(pos2(available.left() + bar, available.top()), available.max)
|
||||
} else {
|
||||
@@ -213,6 +213,12 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
update_clipboard_repaint(app, ui, now);
|
||||
}
|
||||
|
||||
/// The vertical scroll bar's width: none when the workbook hides it (File › Options ›
|
||||
/// Display options for this workbook), and the grid takes its room.
|
||||
fn vertical_bar_width(app: &SheetApp, available: Rect) -> f32 {
|
||||
if app.session.active().is_none_or(|d| d.wb.view.show_vertical_scroll) { 14.0_f32.min(available.width()) } else { 0.0 }
|
||||
}
|
||||
|
||||
fn vertical_scroll_track(available: Rect, grid: Rect, rtl: bool) -> (f32, f32) {
|
||||
if rtl { (available.left(), grid.left()) } else { (grid.right(), available.right()) }
|
||||
}
|
||||
@@ -404,7 +410,19 @@ fn paint_panes(input: PanesInput<'_>, cf: &mut CfCache) -> Vec<PaneQuad> {
|
||||
continue;
|
||||
}
|
||||
let p = painter.with_clip_rect(*clip);
|
||||
paint_quadrant(QuadrantPaint { painter: &p, geo, workbook: wb, sheet_index: si, sheet: sh, rows: *rows, cols: *cols, cf, tokens: t });
|
||||
let active_row = app.ui.highlight_active_row.then_some(sel.active.row);
|
||||
paint_quadrant(QuadrantPaint {
|
||||
painter: &p,
|
||||
geo,
|
||||
workbook: wb,
|
||||
sheet_index: si,
|
||||
sheet: sh,
|
||||
rows: *rows,
|
||||
cols: *cols,
|
||||
cf,
|
||||
tokens: t,
|
||||
active_row,
|
||||
});
|
||||
paint_selection(SelectionPaintInput { painter: &p, geo, sheet: sh, selection: sel, outline, tokens: t, app, rows: *rows, cols: *cols });
|
||||
}
|
||||
// Freeze lines.
|
||||
@@ -420,6 +438,10 @@ fn paint_panes(input: PanesInput<'_>, cf: &mut CfCache) -> Vec<PaneQuad> {
|
||||
/// The worksheet is painted virtually, so it needs its own scrollbar rather than a
|
||||
/// ScrollArea containing all million rows. Keep the scroll extent stable during a drag.
|
||||
fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, track_x: (f32, f32), limit: f32, t: &Tokens) {
|
||||
// A workbook that hides its vertical scroll bar leaves it no width.
|
||||
if track_x.1 <= track_x.0 {
|
||||
return;
|
||||
}
|
||||
let track = Rect::from_min_max(pos2(track_x.0, geo.cells.top() + geo.frozen_h), pos2(track_x.1, geo.cells.bottom()));
|
||||
if track.height() <= 0.0 || track.width() <= 0.0 {
|
||||
return;
|
||||
|
||||
@@ -335,6 +335,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Options|General" => "Obecné",
|
||||
"Options|Save" => "Uložit",
|
||||
"Show formula bar" => "Zobrazit řádek vzorců",
|
||||
"Highlight the active cell's row" => "Zvýraznit řádek aktivní buňky",
|
||||
"Display options for this workbook" => "Možnosti zobrazení pro tento sešit",
|
||||
"Show horizontal scroll bar" => "Zobrazit vodorovný posuvník",
|
||||
"Show vertical scroll bar" => "Zobrazit svislý posuvník",
|
||||
"Show sheet tabs" => "Zobrazit karty listů",
|
||||
"Edit Hyperlink…" => "Upravit hypertextový odkaz…",
|
||||
"Edit Hyperlink" => "Upravit hypertextový odkaz",
|
||||
"Open Hyperlink" => "Otevřít hypertextový odkaz",
|
||||
"Remove Hyperlink" => "Odebrat hypertextový odkaz",
|
||||
"Resize formula bar" => "Změnit velikost řádku vzorců",
|
||||
"Enable AutoComplete for cell values" => "Povolit automatické dokončování hodnot buněk",
|
||||
"Save .xlsx workbooks automatically after every change" => "Automaticky ukládat sešity .xlsx po každé změně",
|
||||
"Workbook calculation" => "Výpočet sešitu",
|
||||
|
||||
@@ -350,6 +350,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Options|General" => "Allgemein",
|
||||
"Options|Save" => "Speichern",
|
||||
"Show formula bar" => "Bearbeitungsleiste anzeigen",
|
||||
"Highlight the active cell's row" => "Zeile der aktiven Zelle hervorheben",
|
||||
"Display options for this workbook" => "Optionen für die Anzeige dieser Arbeitsmappe",
|
||||
"Show horizontal scroll bar" => "Horizontale Bildlaufleiste anzeigen",
|
||||
"Show vertical scroll bar" => "Vertikale Bildlaufleiste anzeigen",
|
||||
"Show sheet tabs" => "Blattregisterkarten anzeigen",
|
||||
"Edit Hyperlink…" => "Link bearbeiten…",
|
||||
"Edit Hyperlink" => "Link bearbeiten",
|
||||
"Open Hyperlink" => "Link öffnen",
|
||||
"Remove Hyperlink" => "Link entfernen",
|
||||
"Resize formula bar" => "Größe der Bearbeitungsleiste ändern",
|
||||
"Enable AutoComplete for cell values" => "AutoVervollständigen für Zellwerte aktivieren",
|
||||
"Save .xlsx workbooks automatically after every change" => ".xlsx-Arbeitsmappen nach jeder Änderung automatisch speichern",
|
||||
"Workbook calculation" => "Arbeitsmappenberechnung",
|
||||
|
||||
@@ -337,6 +337,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Options|General" => "General",
|
||||
"Options|Save" => "Guardar",
|
||||
"Show formula bar" => "Mostrar barra de fórmulas",
|
||||
"Highlight the active cell's row" => "Resaltar la fila de la celda activa",
|
||||
"Display options for this workbook" => "Opciones de presentación para este libro",
|
||||
"Show horizontal scroll bar" => "Mostrar barra de desplazamiento horizontal",
|
||||
"Show vertical scroll bar" => "Mostrar barra de desplazamiento vertical",
|
||||
"Show sheet tabs" => "Mostrar pestañas de hojas",
|
||||
"Edit Hyperlink…" => "Modificar hipervínculo…",
|
||||
"Edit Hyperlink" => "Modificar hipervínculo",
|
||||
"Open Hyperlink" => "Abrir hipervínculo",
|
||||
"Remove Hyperlink" => "Quitar hipervínculo",
|
||||
"Resize formula bar" => "Cambiar el tamaño de la barra de fórmulas",
|
||||
"Enable AutoComplete for cell values" => "Habilitar Autocompletar para valores de celda",
|
||||
"Save .xlsx workbooks automatically after every change" => "Guardar automáticamente los libros .xlsx tras cada cambio",
|
||||
"Workbook calculation" => "Cálculo del libro",
|
||||
|
||||
@@ -410,6 +410,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Options|Save" => "Salvataggio",
|
||||
"Options|Editing" => "Modifica",
|
||||
"Show formula bar" => "Mostra barra della formula",
|
||||
"Highlight the active cell's row" => "Evidenzia la riga della cella attiva",
|
||||
"Enable AutoComplete for cell values" => "Abilita Completamento automatico per i valori delle celle",
|
||||
"Save .xlsx workbooks automatically after every change" => "Salva automaticamente le cartelle di lavoro .xlsx dopo ogni modifica",
|
||||
"Workbook calculation" => "Calcolo della cartella di lavoro",
|
||||
|
||||
@@ -426,6 +426,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Options|General" => "일반",
|
||||
"Options|Save" => "저장",
|
||||
"Show formula bar" => "수식 입력줄 표시",
|
||||
"Highlight the active cell's row" => "활성 셀의 행 강조 표시",
|
||||
"Display options for this workbook" => "이 통합 문서의 표시 옵션",
|
||||
"Show horizontal scroll bar" => "가로 스크롤 막대 표시",
|
||||
"Show vertical scroll bar" => "세로 스크롤 막대 표시",
|
||||
"Show sheet tabs" => "시트 탭 표시",
|
||||
"Edit Hyperlink…" => "하이퍼링크 편집…",
|
||||
"Edit Hyperlink" => "하이퍼링크 편집",
|
||||
"Open Hyperlink" => "하이퍼링크 열기",
|
||||
"Remove Hyperlink" => "하이퍼링크 제거",
|
||||
"Resize formula bar" => "수식 입력줄 크기 조정",
|
||||
"Enable AutoComplete for cell values" => "셀 값에 자동 완성 사용",
|
||||
"Save .xlsx workbooks automatically after every change" => "변경할 때마다 .xlsx 통합 문서를 자동으로 저장",
|
||||
"Workbook calculation" => "통합 문서 계산",
|
||||
|
||||
@@ -416,6 +416,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Options|General" => "Geral",
|
||||
"Options|Save" => "Salvar",
|
||||
"Show formula bar" => "Mostrar barra de fórmulas",
|
||||
"Highlight the active cell's row" => "Realçar a linha da célula ativa",
|
||||
"Display options for this workbook" => "Opções de exibição para esta pasta de trabalho",
|
||||
"Show horizontal scroll bar" => "Mostrar barra de rolagem horizontal",
|
||||
"Show vertical scroll bar" => "Mostrar barra de rolagem vertical",
|
||||
"Show sheet tabs" => "Mostrar guias de planilhas",
|
||||
"Edit Hyperlink…" => "Editar hiperlink…",
|
||||
"Edit Hyperlink" => "Editar hiperlink",
|
||||
"Open Hyperlink" => "Abrir hiperlink",
|
||||
"Remove Hyperlink" => "Remover hiperlink",
|
||||
"Resize formula bar" => "Redimensionar a barra de fórmulas",
|
||||
"Enable AutoComplete for cell values" => "Habilitar AutoConclusão para valores de célula",
|
||||
"Save .xlsx workbooks automatically after every change" => "Salvar pastas de trabalho .xlsx automaticamente após cada alteração",
|
||||
"Workbook calculation" => "Cálculo da pasta de trabalho",
|
||||
|
||||
@@ -426,6 +426,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Options|General" => "Общие",
|
||||
"Options|Save" => "Сохранение",
|
||||
"Show formula bar" => "Показывать строку формул",
|
||||
"Highlight the active cell's row" => "Выделять строку активной ячейки",
|
||||
"Display options for this workbook" => "Параметры отображения для этой книги",
|
||||
"Show horizontal scroll bar" => "Показывать горизонтальную полосу прокрутки",
|
||||
"Show vertical scroll bar" => "Показывать вертикальную полосу прокрутки",
|
||||
"Show sheet tabs" => "Показывать ярлычки листов",
|
||||
"Edit Hyperlink…" => "Изменить гиперссылку…",
|
||||
"Edit Hyperlink" => "Изменить гиперссылку",
|
||||
"Open Hyperlink" => "Открыть гиперссылку",
|
||||
"Remove Hyperlink" => "Удалить гиперссылку",
|
||||
"Resize formula bar" => "Изменить размер строки формул",
|
||||
"Enable AutoComplete for cell values" => "Включить автозавершение значений ячеек",
|
||||
"Save .xlsx workbooks automatically after every change" => "Автоматически сохранять книги .xlsx после каждого изменения",
|
||||
"Workbook calculation" => "Вычисления в книге",
|
||||
|
||||
@@ -418,6 +418,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Options|General" => "常规",
|
||||
"Options|Save" => "保存",
|
||||
"Show formula bar" => "显示编辑栏",
|
||||
"Highlight the active cell's row" => "突出显示活动单元格所在行",
|
||||
"Display options for this workbook" => "此工作簿的显示选项",
|
||||
"Show horizontal scroll bar" => "显示水平滚动条",
|
||||
"Show vertical scroll bar" => "显示垂直滚动条",
|
||||
"Show sheet tabs" => "显示工作表标签",
|
||||
"Edit Hyperlink…" => "编辑超链接…",
|
||||
"Edit Hyperlink" => "编辑超链接",
|
||||
"Open Hyperlink" => "打开超链接",
|
||||
"Remove Hyperlink" => "删除超链接",
|
||||
"Resize formula bar" => "调整编辑栏大小",
|
||||
"Enable AutoComplete for cell values" => "为单元格值启用记忆式键入",
|
||||
"Save .xlsx workbooks automatically after every change" => "每次更改后自动保存 .xlsx 工作簿",
|
||||
"Workbook calculation" => "工作簿计算",
|
||||
|
||||
@@ -115,6 +115,8 @@ fn lookup_number_formats(ctx: LookupContext) -> Option<Shortcut> {
|
||||
Num7 if ctrl && s => borders("outside"),
|
||||
Minus if ctrl && s => borders("none"),
|
||||
Num8 if ctrl && s => run("selection.currentRegion"),
|
||||
// Ctrl+. (Control on a Mac too): Next Corner of Selection.
|
||||
Period if ctrl && !s => run("selection.nextCorner"),
|
||||
Semicolon if ctrl && s => Shortcut::Time,
|
||||
Quote if ctrl && s => Shortcut::ValueAbove,
|
||||
Quote if ctrl => Shortcut::FormulaAbove,
|
||||
|
||||
@@ -48,6 +48,13 @@ pub fn is_group_level(seq: &str) -> bool {
|
||||
matches!(lookup(seq), Some(Entry::Continue)) || (lookup(seq).is_none() && has_deeper(seq))
|
||||
}
|
||||
|
||||
/// False under a legacy Excel 2003 menu letter that has no ribbon tab (Alt+I, D, V, T): Excel
|
||||
/// paints no badges for those, so the ribbon stays as it is while the sequence is typed.
|
||||
pub fn paints_badges(prefix: &str) -> bool {
|
||||
let Some(root) = prefix.get(..1) else { return true };
|
||||
SEQUENCES.iter().any(|(seq, entry)| matches!(entry, Entry::Tab(_)) && seq.starts_with(root))
|
||||
}
|
||||
|
||||
fn matches_target(action: &Action, target: &str) -> bool {
|
||||
match action {
|
||||
Action::Command(id) | Action::CommandWith(id, _) | Action::Pending(id) | Action::Control(id) => *id == target,
|
||||
@@ -214,6 +221,22 @@ mod tests {
|
||||
assert!(!has_deeper("X"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_menu_letters_finish_without_badges() {
|
||||
assert_eq!(lookup("IR"), Some(Entry::Finish(Action::Command("home.insertRows"))));
|
||||
assert_eq!(lookup("DS"), Some(Entry::Finish(Action::Dialog("sort"))));
|
||||
assert_eq!(lookup("EAA"), Some(Entry::Finish(Action::Command("edit.clearAll"))));
|
||||
for root in ["I", "D", "V", "T"] {
|
||||
assert!(is_group_level(root), "{root} waits for the menu letter");
|
||||
assert!(!paints_badges(root), "{root} has no tab, so no badges");
|
||||
}
|
||||
assert!(paints_badges(""), "tab badges");
|
||||
assert!(paints_badges("HO"));
|
||||
assert!(paints_badges("J"), "the Draw tab's two-letter unit");
|
||||
assert!(paints_badges("E"), "E and O stay aliases of Home");
|
||||
assert_eq!(sequences_for("home.insertRows"), vec!["HIR", "IR"], "the ribbon sequence stays first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_extension_paints_only_continue_levels() {
|
||||
assert_eq!(group_extension("H", &["dialog:pasteSpecial"]), vec!["HV"]);
|
||||
|
||||
@@ -372,5 +372,48 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
|
||||
("FN", Entry::Finish(Action::Command("file.new"))), // Excel: New page; New page
|
||||
("FO", Entry::Finish(Action::Command("file.open"))), // Excel: Open page; fires immediately in Excel; deeper aliases dropped as unreachable
|
||||
("FP", Entry::Finish(Action::Command("file.print"))), // Excel: Print page; containment(Print)
|
||||
("Q", Entry::Finish(Action::Pending("ui.search"))), // pending; Excel: Search / Tell Me; Alt+Q search box does not exist yet (issue #49)
|
||||
("IR", Entry::Finish(Action::Command("home.insertRows"))), // Excel: Insert > Rows; Legacy Excel 2003 menu path (#941).
|
||||
("IC", Entry::Finish(Action::Command("home.insertColumns"))), // Excel: Insert > Columns; Legacy Excel 2003 menu path (#941).
|
||||
("IE", Entry::Finish(Action::Dialog("insertCells"))), // Excel: Insert > Cells; Legacy Excel 2003 menu path (#941).
|
||||
("IW", Entry::Finish(Action::Command("home.insertSheet"))), // Excel: Insert > Worksheet; Legacy Excel 2003 menu path (#941).
|
||||
("IF", Entry::Finish(Action::Dialog("insertFunction"))), // Excel: Insert > Function; Legacy Excel 2003 menu path (#941).
|
||||
("IND", Entry::Finish(Action::Dialog("defineName"))), // Excel: Insert > Name > Define; Legacy Excel 2003 menu path (#941).
|
||||
("IS", Entry::Finish(Action::Control("insert.symbol"))), // Excel: Insert > Symbol; Legacy Excel 2003 menu path (#941).
|
||||
("ED", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Edit > Delete; Legacy Excel 2003 menu path (#941).
|
||||
("EM", Entry::Finish(Action::Dialog("moveSheet"))), // Excel: Edit > Move or Copy Sheet; Legacy Excel 2003 menu path (#941).
|
||||
("EL", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Edit > Delete Sheet; Legacy Excel 2003 menu path (#941).
|
||||
("EAA", Entry::Finish(Action::Command("edit.clearAll"))), // Excel: Edit > Clear > All; Legacy Excel 2003 menu path (#941).
|
||||
("EAC", Entry::Finish(Action::Command("edit.clearContents"))), // Excel: Edit > Clear > Contents; Legacy Excel 2003 menu path (#941).
|
||||
("EAM", Entry::Finish(Action::Command("edit.clearComments"))), // Excel: Edit > Clear > Comments; Legacy Excel 2003 menu path (#941).
|
||||
("EF", Entry::Finish(Action::Dialog("find"))), // Excel: Edit > Find; Legacy Excel 2003 menu path (#941).
|
||||
("EG", Entry::Finish(Action::Dialog("goTo"))), // Excel: Edit > Go To; Legacy Excel 2003 menu path (#941).
|
||||
("ORA", Entry::Finish(Action::Command("home.autofitRowHeight"))), // Excel: Format > Row > AutoFit; Legacy Excel 2003 menu path (#941).
|
||||
("ORE", Entry::Finish(Action::Dialog("rowHeight"))), // Excel: Format > Row > Height; Legacy Excel 2003 menu path (#941).
|
||||
("ORH", Entry::Finish(Action::Command("home.hideRows"))), // Excel: Format > Row > Hide; Legacy Excel 2003 menu path (#941).
|
||||
("ORU", Entry::Finish(Action::Command("home.unhideRows"))), // Excel: Format > Row > Unhide; Legacy Excel 2003 menu path (#941).
|
||||
("OCW", Entry::Finish(Action::Dialog("columnWidth"))), // Excel: Format > Column > Width; Legacy Excel 2003 menu path (#941).
|
||||
("OCH", Entry::Finish(Action::Command("home.hideColumns"))), // Excel: Format > Column > Hide; Legacy Excel 2003 menu path (#941).
|
||||
("OCU", Entry::Finish(Action::Command("home.unhideColumns"))), // Excel: Format > Column > Unhide; Legacy Excel 2003 menu path (#941).
|
||||
("OHR", Entry::Finish(Action::Dialog("renameSheet"))), // Excel: Format > Sheet > Rename; Legacy Excel 2003 menu path (#941).
|
||||
("OHH", Entry::Finish(Action::Command("sheet.hide"))), // Excel: Format > Sheet > Hide; Legacy Excel 2003 menu path (#941).
|
||||
("OHU", Entry::Finish(Action::Command("sheet.unhide"))), // Excel: Format > Sheet > Unhide; Legacy Excel 2003 menu path (#941).
|
||||
("OE", Entry::Finish(Action::Command("home.formatCells"))), // Excel: Format > Cells; Legacy Excel 2003 menu path (#941).
|
||||
("OD", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Format > Conditional Formatting; Legacy Excel 2003 menu path (#941).
|
||||
("DFF", Entry::Finish(Action::Command("data.filter"))), // Excel: Data > Filter > AutoFilter; Legacy Excel 2003 menu path (#941).
|
||||
("DS", Entry::Finish(Action::Dialog("sort"))), // Excel: Data > Sort; Legacy Excel 2003 menu path (#941).
|
||||
("DGG", Entry::Finish(Action::Command("data.group"))), // Excel: Data > Group and Outline > Group; Legacy Excel 2003 menu path (#941).
|
||||
("DGU", Entry::Finish(Action::Command("data.ungroup"))), // Excel: Data > Group and Outline > Ungroup; Legacy Excel 2003 menu path (#941).
|
||||
("DGS", Entry::Finish(Action::Command("data.showDetail"))), // Excel: Data > Group and Outline > Show Detail; Legacy Excel 2003 menu path (#941).
|
||||
("DGH", Entry::Finish(Action::Command("data.hideDetail"))), // Excel: Data > Group and Outline > Hide Detail; Legacy Excel 2003 menu path (#941).
|
||||
("DL", Entry::Finish(Action::Command("data.validation"))), // Excel: Data > Validation; Legacy Excel 2003 menu path (#941).
|
||||
("DT", Entry::Finish(Action::Command("data.dataTable"))), // Excel: Data > Table (what-if data table); Legacy Excel 2003 menu path (#941).
|
||||
("VN", Entry::Finish(Action::Command("view.normal"))), // Excel: View > Normal; Legacy Excel 2003 menu path (#941).
|
||||
("VP", Entry::Finish(Action::Command("view.pageBreakPreview"))), // Excel: View > Page Break Preview; Legacy Excel 2003 menu path (#941).
|
||||
("VZ", Entry::Finish(Action::Dialog("zoom"))), // Excel: View > Zoom; Legacy Excel 2003 menu path (#941).
|
||||
("TO", Entry::Finish(Action::Dialog("options"))), // Excel: Tools > Options; Legacy Excel 2003 menu path (#941).
|
||||
("TUA", Entry::Finish(Action::Command("formulas.removeArrows"))), // Excel: Tools > Formula Auditing > Remove All Arrows; Legacy Excel 2003 menu path (#941).
|
||||
("TUD", Entry::Finish(Action::Command("formulas.traceDependents"))), // Excel: Tools > Formula Auditing > Trace Dependents; Legacy Excel 2003 menu path (#941).
|
||||
("TUT", Entry::Finish(Action::Command("formulas.tracePrecedents"))), // Excel: Tools > Formula Auditing > Trace Precedents; Legacy Excel 2003 menu path (#941).
|
||||
("FU", Entry::Finish(Action::Dialog("pageSetup"))), // Excel: File > Page Setup; Legacy Excel 2003 menu path (#941).
|
||||
("Q", Entry::Finish(Action::Pending("ui.search"))), // pending; Excel: Search / Tell Me; Alt+Q search box does not exist yet (issue #49)
|
||||
];
|
||||
|
||||
@@ -2582,6 +2582,307 @@
|
||||
"excel": "Print page",
|
||||
"note": "containment(Print)"
|
||||
},
|
||||
{
|
||||
"seq": "IR",
|
||||
"kind": "command",
|
||||
"target": "home.insertRows",
|
||||
"excel": "Insert > Rows",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "IC",
|
||||
"kind": "command",
|
||||
"target": "home.insertColumns",
|
||||
"excel": "Insert > Columns",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "IE",
|
||||
"kind": "dialog",
|
||||
"target": "insertCells",
|
||||
"excel": "Insert > Cells",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "IW",
|
||||
"kind": "command",
|
||||
"target": "home.insertSheet",
|
||||
"excel": "Insert > Worksheet",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "IF",
|
||||
"kind": "dialog",
|
||||
"target": "insertFunction",
|
||||
"excel": "Insert > Function",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "IND",
|
||||
"kind": "dialog",
|
||||
"target": "defineName",
|
||||
"excel": "Insert > Name > Define",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "IS",
|
||||
"kind": "control",
|
||||
"target": "insert.symbol",
|
||||
"excel": "Insert > Symbol",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "ED",
|
||||
"kind": "dialog",
|
||||
"target": "deleteCells",
|
||||
"excel": "Edit > Delete",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "EM",
|
||||
"kind": "dialog",
|
||||
"target": "moveSheet",
|
||||
"excel": "Edit > Move or Copy Sheet",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "EL",
|
||||
"kind": "command",
|
||||
"target": "home.deleteSheet",
|
||||
"excel": "Edit > Delete Sheet",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "EAA",
|
||||
"kind": "command",
|
||||
"target": "edit.clearAll",
|
||||
"excel": "Edit > Clear > All",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "EAC",
|
||||
"kind": "command",
|
||||
"target": "edit.clearContents",
|
||||
"excel": "Edit > Clear > Contents",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "EAM",
|
||||
"kind": "command",
|
||||
"target": "edit.clearComments",
|
||||
"excel": "Edit > Clear > Comments",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "EF",
|
||||
"kind": "dialog",
|
||||
"target": "find",
|
||||
"excel": "Edit > Find",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "EG",
|
||||
"kind": "dialog",
|
||||
"target": "goTo",
|
||||
"excel": "Edit > Go To",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "ORA",
|
||||
"kind": "command",
|
||||
"target": "home.autofitRowHeight",
|
||||
"excel": "Format > Row > AutoFit",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "ORE",
|
||||
"kind": "dialog",
|
||||
"target": "rowHeight",
|
||||
"excel": "Format > Row > Height",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "ORH",
|
||||
"kind": "command",
|
||||
"target": "home.hideRows",
|
||||
"excel": "Format > Row > Hide",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "ORU",
|
||||
"kind": "command",
|
||||
"target": "home.unhideRows",
|
||||
"excel": "Format > Row > Unhide",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "OCW",
|
||||
"kind": "dialog",
|
||||
"target": "columnWidth",
|
||||
"excel": "Format > Column > Width",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "OCH",
|
||||
"kind": "command",
|
||||
"target": "home.hideColumns",
|
||||
"excel": "Format > Column > Hide",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "OCU",
|
||||
"kind": "command",
|
||||
"target": "home.unhideColumns",
|
||||
"excel": "Format > Column > Unhide",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "OHR",
|
||||
"kind": "dialog",
|
||||
"target": "renameSheet",
|
||||
"excel": "Format > Sheet > Rename",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "OHH",
|
||||
"kind": "command",
|
||||
"target": "sheet.hide",
|
||||
"excel": "Format > Sheet > Hide",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "OHU",
|
||||
"kind": "command",
|
||||
"target": "sheet.unhide",
|
||||
"excel": "Format > Sheet > Unhide",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "OE",
|
||||
"kind": "command",
|
||||
"target": "home.formatCells",
|
||||
"excel": "Format > Cells",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "OD",
|
||||
"kind": "command",
|
||||
"target": "home.conditionalFormat",
|
||||
"excel": "Format > Conditional Formatting",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "DFF",
|
||||
"kind": "command",
|
||||
"target": "data.filter",
|
||||
"excel": "Data > Filter > AutoFilter",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "DS",
|
||||
"kind": "dialog",
|
||||
"target": "sort",
|
||||
"excel": "Data > Sort",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "DGG",
|
||||
"kind": "command",
|
||||
"target": "data.group",
|
||||
"excel": "Data > Group and Outline > Group",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "DGU",
|
||||
"kind": "command",
|
||||
"target": "data.ungroup",
|
||||
"excel": "Data > Group and Outline > Ungroup",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "DGS",
|
||||
"kind": "command",
|
||||
"target": "data.showDetail",
|
||||
"excel": "Data > Group and Outline > Show Detail",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "DGH",
|
||||
"kind": "command",
|
||||
"target": "data.hideDetail",
|
||||
"excel": "Data > Group and Outline > Hide Detail",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "DL",
|
||||
"kind": "command",
|
||||
"target": "data.validation",
|
||||
"excel": "Data > Validation",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "DT",
|
||||
"kind": "command",
|
||||
"target": "data.dataTable",
|
||||
"excel": "Data > Table (what-if data table)",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "VN",
|
||||
"kind": "command",
|
||||
"target": "view.normal",
|
||||
"excel": "View > Normal",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "VP",
|
||||
"kind": "command",
|
||||
"target": "view.pageBreakPreview",
|
||||
"excel": "View > Page Break Preview",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "VZ",
|
||||
"kind": "dialog",
|
||||
"target": "zoom",
|
||||
"excel": "View > Zoom",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "TO",
|
||||
"kind": "dialog",
|
||||
"target": "options",
|
||||
"excel": "Tools > Options",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "TUA",
|
||||
"kind": "command",
|
||||
"target": "formulas.removeArrows",
|
||||
"excel": "Tools > Formula Auditing > Remove All Arrows",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "TUD",
|
||||
"kind": "command",
|
||||
"target": "formulas.traceDependents",
|
||||
"excel": "Tools > Formula Auditing > Trace Dependents",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "TUT",
|
||||
"kind": "command",
|
||||
"target": "formulas.tracePrecedents",
|
||||
"excel": "Tools > Formula Auditing > Trace Precedents",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "FU",
|
||||
"kind": "dialog",
|
||||
"target": "pageSetup",
|
||||
"excel": "File > Page Setup",
|
||||
"note": "Legacy Excel 2003 menu path (#941)."
|
||||
},
|
||||
{
|
||||
"seq": "Q",
|
||||
"kind": "command",
|
||||
|
||||
@@ -88,12 +88,18 @@ pub struct UiState {
|
||||
pub ribbon_tab: String,
|
||||
pub ribbon_collapsed: bool,
|
||||
pub formula_bar_expanded: bool,
|
||||
/// Lines the expanded formula bar shows: the height its bottom edge was last dragged to.
|
||||
/// Expanding it again (the chevron, Ctrl+Shift+U) returns to that height.
|
||||
pub formula_bar_lines: usize,
|
||||
pub status_bar: bool,
|
||||
/// Motion in the UI: the selection outline glides to a new selection (View ▸ Animations).
|
||||
pub animations: bool,
|
||||
/// Enable AutoComplete for cell values (File ▸ Options ▸ Editing): typed text is completed
|
||||
/// from the one matching entry in the same column.
|
||||
pub autocomplete: bool,
|
||||
/// Tint the active cell's whole row (File ▸ Options ▸ General), on every sheet and workbook, so
|
||||
/// the eye keeps its place across a wide table. A preference: nothing goes into the file.
|
||||
pub highlight_active_row: bool,
|
||||
/// AutoSave (the title-bar switch): save saved-to-file .xlsx workbooks shortly after every
|
||||
/// change. A preference like the rest of this struct — it stays on across restarts (#1901).
|
||||
pub autosave: bool,
|
||||
@@ -102,6 +108,10 @@ pub struct UiState {
|
||||
/// [`keytips::Action::Control`], consumed (and cleared) by the control's render site.
|
||||
#[serde(skip)]
|
||||
pub keytip_invoke: Option<&'static str>,
|
||||
/// A keytip control request for the next frame: its tab was switched to this frame, so its
|
||||
/// control is drawn only then.
|
||||
#[serde(skip)]
|
||||
pub keytip_invoke_next: Option<&'static str>,
|
||||
/// Interface language. English by default (so tests and headless renders never depend on the
|
||||
/// host's locale); the desktop app starts from [`i18n::Language::system`] when `ui.json` has
|
||||
/// no usable language (see [`i18n::saved_language`]). An unknown saved value reads as English
|
||||
@@ -137,12 +147,15 @@ impl Default for UiState {
|
||||
ribbon_tab: "Home".into(),
|
||||
ribbon_collapsed: false,
|
||||
formula_bar_expanded: false,
|
||||
formula_bar_lines: formula_bar::MAX_LINES,
|
||||
status_bar: true,
|
||||
animations: true,
|
||||
autocomplete: true,
|
||||
highlight_active_row: false,
|
||||
autosave: false,
|
||||
recent: vec![],
|
||||
keytip_invoke: None,
|
||||
keytip_invoke_next: None,
|
||||
language: i18n::Language::En,
|
||||
interface_scale: 100,
|
||||
}
|
||||
|
||||
@@ -459,10 +459,7 @@ pub fn parse_refs(text: &str) -> Vec<(Option<String>, RangeRef)> {
|
||||
text.split(',')
|
||||
.filter_map(|part| {
|
||||
let part = part.trim();
|
||||
let (sheet, body) = match part.rsplit_once('!') {
|
||||
Some((s, b)) => (Some(s.trim().trim_matches('\'').replace("''", "'")), b),
|
||||
None => (None, part),
|
||||
};
|
||||
let (sheet, body) = gridcraft_engine::core::split_sheet(part);
|
||||
RangeRef::parse(&body.replace('$', "")).map(|r| (sheet, r))
|
||||
})
|
||||
.take(500)
|
||||
@@ -2399,6 +2396,15 @@ pub fn paint_overlay(p: &egui::Painter, geo: &crate::grid::Geo, sh: &gridcraft_e
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn example_references_unquote_sheet_names_as_formulas_do() {
|
||||
// #2012: one shared reading of `'Bob''s'!A1` and `'a!b'!A1`.
|
||||
let refs = parse_refs("'Bob''s'!$A$1:$B$2, 'a!b'!C3, D4");
|
||||
let sheets: Vec<Option<String>> = refs.iter().map(|(s, _)| s.clone()).collect();
|
||||
assert_eq!(sheets, [Some("Bob's".to_string()), Some("a!b".to_string()), None]);
|
||||
assert_eq!(refs.iter().map(|(_, r)| r.a1()).collect::<Vec<_>>(), ["A1:B2", "C3", "D4"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edited_input_keeps_a_shape_a_macro_may_declare() {
|
||||
let range = NamedInput { rows: [2, 10], cols: [3, 3], header: true, col_types: vec![ColType::Number; 3], ..NamedInput::default() };
|
||||
|
||||
@@ -162,10 +162,26 @@ pub(crate) fn paint_pages(input: PagePaintInput<'_>, cf: &mut CfCache) -> Vec<gr
|
||||
}
|
||||
painted += 1;
|
||||
let pc = p.with_clip_rect(clip);
|
||||
grid::paint_quadrant(grid::QuadrantPaint { painter: &pc, geo, workbook: wb, sheet_index: si, sheet: sh, rows, cols, cf, tokens: t });
|
||||
let active_row = app.ui.highlight_active_row.then_some(sel.active.row);
|
||||
grid::paint_quadrant(grid::QuadrantPaint {
|
||||
painter: &pc,
|
||||
geo,
|
||||
workbook: wb,
|
||||
sheet_index: si,
|
||||
sheet: sh,
|
||||
rows,
|
||||
cols,
|
||||
cf,
|
||||
tokens: t,
|
||||
active_row,
|
||||
});
|
||||
if !pg.is_data_page(i, j) {
|
||||
// "Click to add data" pages: faded until something is typed there.
|
||||
pc.rect_filled(clip, 0.0, t.grid_bg.gamma_multiply(0.75));
|
||||
// The fade would all but hide the active row's band: it goes on again.
|
||||
if let Some(band) = active_row.and_then(|row| grid::active_row_band(geo, sh, row, rows, cols)) {
|
||||
pc.rect_filled(band, 0.0, t.active_row);
|
||||
}
|
||||
let size = (14.0 * geo.z / DISPLAY_SCALE).clamp(8.0, 22.0);
|
||||
pc.text(clip.center(), Align2::CENTER_CENTER, tr(app, "Click to add data"), theme::ui_font(size), Color32::from_gray(150));
|
||||
}
|
||||
|
||||
@@ -27,7 +27,15 @@ pub(super) fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) {
|
||||
|
||||
/// Keytips are painted, not focusable widgets: the grid retains keyboard focus.
|
||||
pub(super) fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) {
|
||||
let Some(rest) = app.keytips.prefix().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return };
|
||||
let Some(rest) = app
|
||||
.keytips
|
||||
.prefix()
|
||||
.filter(|prefix| keytip_table::paints_badges(prefix))
|
||||
.and_then(|prefix| sequence.strip_prefix(prefix))
|
||||
.filter(|s| !s.is_empty())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("ribbon_keytips"))).with_clip_rect(ui.clip_rect());
|
||||
let text = painter.layout_no_wrap(rest.into(), theme::ui_bold(10.5), t.text);
|
||||
|
||||
@@ -622,10 +622,13 @@ fn size_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) {
|
||||
let arrow_rect = Rect::from_min_max(egui::pos2(frame.right() - 16.0, frame.top() + 1.0), egui::pos2(frame.right() - 1.0, frame.bottom() - 1.0));
|
||||
let mut field_ui =
|
||||
ui.new_child(egui::UiBuilder::new().max_rect(text_rect.shrink2(vec2(6.0, 0.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
// The text edit is `interact_size.y` tall and puts its text at the top of that unless told
|
||||
// otherwise: center it, on the Font name's baseline (#3155).
|
||||
let out = egui::TextEdit::singleline(&mut text)
|
||||
.id(field)
|
||||
.font(theme::ui_font(12.5))
|
||||
.frame(egui::Frame::NONE)
|
||||
.align(egui::Align2::LEFT_CENTER)
|
||||
.desired_width(text_rect.width() - 12.0)
|
||||
.show(&mut field_ui);
|
||||
let resp = out.response;
|
||||
|
||||
@@ -213,7 +213,9 @@ pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) {
|
||||
});
|
||||
let sym = big_button(ui, Icon::Symbol, "Symbol", "Symbol", true);
|
||||
control_keytip(app, ui, &sym, "insert.symbol");
|
||||
egui::Popup::menu(&sym).show(|ui| {
|
||||
// Opened by its key tip too (Alt+I, S): the gallery picks the symbol, nothing is inserted before.
|
||||
let forced = invoke(app, "insert.symbol");
|
||||
egui::Popup::menu(&sym).open_memory(popup_command(forced, sym.clicked())).show(|ui| {
|
||||
ui.set_width(260.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for ch in [
|
||||
|
||||
@@ -32,6 +32,9 @@ pub struct Services {
|
||||
/// gets it from egui's `Event::Paste`). `None` from the host, or no service (web), pastes
|
||||
/// GridCraft's own clipboard.
|
||||
pub paste_text: Option<Box<dyn FnMut() -> Option<String>>>,
|
||||
/// Read the picture on the system clipboard for the ribbon and menu Paste commands when it
|
||||
/// holds no text (Cmd/Ctrl+V gets it from egui's `Event::PasteImage`).
|
||||
pub paste_image: Option<Box<dyn FnMut() -> Option<image::RgbaImage>>>,
|
||||
/// Web: deliver a file to the user (name, bytes).
|
||||
pub download: Option<Download>,
|
||||
/// Web: start an asynchronous file pick; the bytes arrive later in `inbox`.
|
||||
|
||||
+176
-152
@@ -100,29 +100,40 @@ fn tab_split_handle(app: &mut SheetApp, ui: &mut Ui, split: Rect, bar: (f32, f32
|
||||
/// Draws the worksheet tab strip: navigation, scroll/reveal, rename/drag/move with optional copy, context menu, new-sheet button, split handle and grid scrollbar.
|
||||
pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// Only the controls drawn this frame have a rectangle: a hidden one keeps no stale geometry.
|
||||
app.grid.hscroll = None;
|
||||
app.grid.tab_split = None;
|
||||
// File › Options › Advanced › Display options for this workbook (#3101): with neither the
|
||||
// sheet tabs nor the horizontal scroll bar, the bar isn't there at all.
|
||||
let view = app.session.active().map(|d| d.wb.view).unwrap_or_default();
|
||||
if !view.show_sheet_tabs && !view.show_horizontal_scroll {
|
||||
return;
|
||||
}
|
||||
egui::Panel::bottom("sheet_tabs").exact_size(32.0).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| {
|
||||
let rect = ui.max_rect();
|
||||
ui.painter().line_segment([rect.left_top(), rect.right_top()], Stroke::new(1.0, t.ribbon_border));
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
ui.add_space(8.0);
|
||||
if crate::widgets::icon_button(ui, Icon::Left, t.text_dim, app.ui.language.tr("Previous sheet"), vec2(24.0, 24.0)).clicked() {
|
||||
app.run_or_alert("sheet.previous", json!({}));
|
||||
if view.show_sheet_tabs {
|
||||
if crate::widgets::icon_button(ui, Icon::Left, t.text_dim, app.ui.language.tr("Previous sheet"), vec2(24.0, 24.0)).clicked() {
|
||||
app.run_or_alert("sheet.previous", json!({}));
|
||||
}
|
||||
if crate::widgets::icon_button(ui, Icon::Right, t.text_dim, app.ui.language.tr("Next sheet"), vec2(24.0, 24.0)).clicked() {
|
||||
app.run_or_alert("sheet.next", json!({}));
|
||||
}
|
||||
let all = crate::widgets::icon_button(ui, Icon::SheetList, t.text_dim, app.ui.language.tr("All sheets"), vec2(24.0, 24.0));
|
||||
all.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, app.ui.language.tr("All sheets")));
|
||||
let items = app.session.active().map(|d| navigator_items(&d.wb.sheets)).unwrap_or_default();
|
||||
let active = app.session.active().map(|d| d.wb.active_sheet().to_string()).unwrap_or_default();
|
||||
let rows: Vec<(&str, String)> = items.iter().map(|(i, name)| (i.as_str(), name.clone())).collect();
|
||||
if let crate::ribbon::ComboResult::Picked(i) = crate::ribbon::editable_combo(ui, &all, "sheet_navigator", &active, &rows, false)
|
||||
&& let Ok(i) = i.parse::<usize>()
|
||||
{
|
||||
activate_sheet(app, i);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
if crate::widgets::icon_button(ui, Icon::Right, t.text_dim, app.ui.language.tr("Next sheet"), vec2(24.0, 24.0)).clicked() {
|
||||
app.run_or_alert("sheet.next", json!({}));
|
||||
}
|
||||
let all = crate::widgets::icon_button(ui, Icon::SheetList, t.text_dim, app.ui.language.tr("All sheets"), vec2(24.0, 24.0));
|
||||
all.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, app.ui.language.tr("All sheets")));
|
||||
let items = app.session.active().map(|d| navigator_items(&d.wb.sheets)).unwrap_or_default();
|
||||
let active = app.session.active().map(|d| d.wb.active_sheet().to_string()).unwrap_or_default();
|
||||
let rows: Vec<(&str, String)> = items.iter().map(|(i, name)| (i.as_str(), name.clone())).collect();
|
||||
if let crate::ribbon::ComboResult::Picked(i) = crate::ribbon::editable_combo(ui, &all, "sheet_navigator", &active, &rows, false)
|
||||
&& let Ok(i) = i.parse::<usize>()
|
||||
{
|
||||
activate_sheet(app, i);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let Some(d) = app.session.active() else { return };
|
||||
let (bar_left, bar_width) = (ui.cursor().left(), ui.available_width());
|
||||
let uid = d.uid;
|
||||
@@ -143,6 +154,8 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
||||
// After the handle at the scrollbar's left edge is dragged, the tabs and New sheet
|
||||
// take the share it was dragged to.
|
||||
let tabs_width = match share {
|
||||
// Without the scroll bar the tabs have the whole bar.
|
||||
_ if !view.show_horizontal_scroll => (bar_width - NEW_SHEET_WIDTH).max(0.0),
|
||||
Some(share) => (bar_width * share - NEW_SHEET_WIDTH).max(0.0),
|
||||
None => (bar_width * 0.6).max(100.0),
|
||||
};
|
||||
@@ -155,154 +168,159 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
||||
data.insert_temp(reveal_id, reveal_key);
|
||||
changed
|
||||
});
|
||||
ui.allocate_ui_with_layout(vec2(tabs_width, 30.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
// Translate vertical wheel movement into horizontal
|
||||
// worksheet-tab scrolling.
|
||||
let wheel_scroll = if ui.rect_contains_pointer(ui.max_rect()) {
|
||||
ui.input(|i| {
|
||||
let d = i.smooth_scroll_delta;
|
||||
if d.x.abs() > d.y.abs() { d.x } else { d.y }
|
||||
})
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if view.show_sheet_tabs {
|
||||
ui.allocate_ui_with_layout(vec2(tabs_width, 30.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
// Translate vertical wheel movement into horizontal
|
||||
// worksheet-tab scrolling.
|
||||
let wheel_scroll = if ui.rect_contains_pointer(ui.max_rect()) {
|
||||
ui.input(|i| {
|
||||
let d = i.smooth_scroll_delta;
|
||||
if d.x.abs() > d.y.abs() { d.x } else { d.y }
|
||||
})
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
egui::ScrollArea::horizontal()
|
||||
.id_salt("worksheet_tabs_horizontal")
|
||||
.auto_shrink([true, false])
|
||||
.min_scrolled_width(0.0)
|
||||
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
|
||||
.show(ui, |ui| {
|
||||
if wheel_scroll.abs() > 0.0 {
|
||||
ui.scroll_with_delta(vec2(wheel_scroll, 0.0));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
for (i, name, color, active) in sheets {
|
||||
let renaming = app.grid.renaming_tab == Some(i);
|
||||
let font = if active { theme::ui_bold(13.0) } else { theme::ui_font(13.0) };
|
||||
let tw = ui.painter().layout_no_wrap(name.clone(), font.clone(), t.text).size().x;
|
||||
let w = (tw + 32.0).max(72.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click_and_drag());
|
||||
if active && reveal_active {
|
||||
resp.scroll_to_me(None);
|
||||
}
|
||||
let r = r.shrink2(vec2(0.0, 1.0));
|
||||
if active {
|
||||
ui.painter().rect_filled(Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), 4.0, t.tab_active);
|
||||
ui.painter().rect_stroke(
|
||||
Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max),
|
||||
4.0,
|
||||
Stroke::new(1.0, t.ribbon_border),
|
||||
StrokeKind::Inside,
|
||||
);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 4.0, t.hover);
|
||||
}
|
||||
if let Some(c) = color {
|
||||
let band = Rect::from_min_size(pos2(r.left() + 6.0, r.bottom() - 4.0), vec2(r.width() - 12.0, 3.0));
|
||||
ui.painter().rect_filled(band, 1.0, theme::color32(c));
|
||||
} else if active {
|
||||
let band = Rect::from_center_size(pos2(r.center().x, r.bottom() - 3.0), vec2(tw.min(48.0), 2.5));
|
||||
ui.painter().rect_filled(band, 1.0, t.accent);
|
||||
}
|
||||
if renaming {
|
||||
let mut text = app.grid.rename_text.clone();
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(6.0, 5.0))));
|
||||
let ed = child
|
||||
.add(egui::TextEdit::singleline(&mut text).font(theme::ui_font(13.0)).desired_width(r.width() - 12.0))
|
||||
.accessible_name(app.ui.language.tr("Sheet Name"));
|
||||
app.grid.rename_text = text.clone();
|
||||
if rename_needs_focus(ed.has_focus(), ed.lost_focus()) {
|
||||
ed.request_focus();
|
||||
egui::ScrollArea::horizontal()
|
||||
.id_salt("worksheet_tabs_horizontal")
|
||||
.auto_shrink([true, false])
|
||||
.min_scrolled_width(0.0)
|
||||
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
|
||||
.show(ui, |ui| {
|
||||
if wheel_scroll.abs() > 0.0 {
|
||||
ui.scroll_with_delta(vec2(wheel_scroll, 0.0));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
for (i, name, color, active) in sheets {
|
||||
let renaming = app.grid.renaming_tab == Some(i);
|
||||
let font = if active { theme::ui_bold(13.0) } else { theme::ui_font(13.0) };
|
||||
let tw = ui.painter().layout_no_wrap(name.clone(), font.clone(), t.text).size().x;
|
||||
let w = (tw + 32.0).max(72.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click_and_drag());
|
||||
if active && reveal_active {
|
||||
resp.scroll_to_me(None);
|
||||
}
|
||||
if ed.lost_focus() {
|
||||
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && text != name {
|
||||
app.run_or_alert("sheet.rename", json!({"sheet": i, "name": text}));
|
||||
let r = r.shrink2(vec2(0.0, 1.0));
|
||||
if active {
|
||||
ui.painter().rect_filled(Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), 4.0, t.tab_active);
|
||||
ui.painter().rect_stroke(
|
||||
Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max),
|
||||
4.0,
|
||||
Stroke::new(1.0, t.ribbon_border),
|
||||
StrokeKind::Inside,
|
||||
);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 4.0, t.hover);
|
||||
}
|
||||
if let Some(c) = color {
|
||||
let band = Rect::from_min_size(pos2(r.left() + 6.0, r.bottom() - 4.0), vec2(r.width() - 12.0, 3.0));
|
||||
ui.painter().rect_filled(band, 1.0, theme::color32(c));
|
||||
} else if active {
|
||||
let band = Rect::from_center_size(pos2(r.center().x, r.bottom() - 3.0), vec2(tw.min(48.0), 2.5));
|
||||
ui.painter().rect_filled(band, 1.0, t.accent);
|
||||
}
|
||||
if renaming {
|
||||
let mut text = app.grid.rename_text.clone();
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(6.0, 5.0))));
|
||||
let ed = child
|
||||
.add(egui::TextEdit::singleline(&mut text).font(theme::ui_font(13.0)).desired_width(r.width() - 12.0))
|
||||
.accessible_name(app.ui.language.tr("Sheet Name"));
|
||||
app.grid.rename_text = text.clone();
|
||||
if rename_needs_focus(ed.has_focus(), ed.lost_focus()) {
|
||||
ed.request_focus();
|
||||
}
|
||||
app.grid.renaming_tab = None;
|
||||
if ed.lost_focus() {
|
||||
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && text != name {
|
||||
app.run_or_alert("sheet.rename", json!({"sheet": i, "name": text}));
|
||||
}
|
||||
app.grid.renaming_tab = None;
|
||||
}
|
||||
} else {
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, &name, font, if active { t.accent } else { t.text });
|
||||
}
|
||||
} else {
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, &name, font, if active { t.accent } else { t.text });
|
||||
}
|
||||
// A right-click activates the tab too, as in Excel, so the menu's sheet commands (Protect
|
||||
// Sheet, Select All Sheets) act on the sheet that was clicked rather than the one that was active.
|
||||
if resp.clicked() || (resp.secondary_clicked() && !active) {
|
||||
activate_sheet(app, i);
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
app.grid.renaming_tab = Some(i);
|
||||
app.grid.rename_text = name.clone();
|
||||
}
|
||||
// Drag to reorder; with the copy modifier held, drop a copy instead.
|
||||
let copy = drop_copies_sheet(ui.input(|inp| inp.modifiers), ui.ctx().os() == egui::os::OperatingSystem::Mac);
|
||||
if resp.dragged() && copy {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Copy);
|
||||
}
|
||||
if resp.drag_stopped()
|
||||
&& let Some(p) = ui.input(|inp| inp.pointer.interact_pos())
|
||||
{
|
||||
let dx = p.x - r.center().x;
|
||||
let steps = (dx / w).round() as i64;
|
||||
if let Some(params) = tab_drop(i, steps, copy) {
|
||||
app.run_or_alert("sheet.move", params);
|
||||
// A right-click activates the tab too, as in Excel, so the menu's sheet commands (Protect
|
||||
// Sheet, Select All Sheets) act on the sheet that was clicked rather than the one that was active.
|
||||
if resp.clicked() || (resp.secondary_clicked() && !active) {
|
||||
activate_sheet(app, i);
|
||||
}
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
ui.set_min_width(180.0);
|
||||
if ui.button(app.ui.language.tr("Insert Sheet")).clicked() {
|
||||
app.run_or_alert("home.insertSheet", json!({"before": i}));
|
||||
}
|
||||
if ui.button(app.ui.language.tr("Delete")).clicked() {
|
||||
app.run_or_alert("home.deleteSheet", json!({"sheet": i}));
|
||||
}
|
||||
if ui.button(app.ui.language.tr("Rename")).clicked() {
|
||||
if resp.double_clicked() {
|
||||
app.grid.renaming_tab = Some(i);
|
||||
app.grid.rename_text = name.clone();
|
||||
}
|
||||
if ui.button(app.ui.language.tr("Move or Copy…")).clicked() {
|
||||
app.open_dialog("moveSheet", json!({"sheet": i}));
|
||||
// Drag to reorder; with the copy modifier held, drop a copy instead.
|
||||
let copy = drop_copies_sheet(ui.input(|inp| inp.modifiers), ui.ctx().os() == egui::os::OperatingSystem::Mac);
|
||||
if resp.dragged() && copy {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Copy);
|
||||
}
|
||||
if ui.button(app.ui.language.tr("Duplicate")).clicked() {
|
||||
app.run_or_alert("sheet.move", json!({"sheet": i, "to": i + 1, "copy": true}));
|
||||
if resp.drag_stopped()
|
||||
&& let Some(p) = ui.input(|inp| inp.pointer.interact_pos())
|
||||
{
|
||||
let dx = p.x - r.center().x;
|
||||
let steps = (dx / w).round() as i64;
|
||||
if let Some(params) = tab_drop(i, steps, copy) {
|
||||
app.run_or_alert("sheet.move", params);
|
||||
}
|
||||
}
|
||||
ui.menu_button(app.ui.language.tr("Tab Color"), |ui| {
|
||||
let colors = app.session.active().map(|d| d.wb.theme.colors).unwrap_or_default();
|
||||
if let Some(c) = crate::widgets::color_palette_fixed(ui, &colors, app.ui.language.tr("No Color")) {
|
||||
app.run_or_alert("sheet.tabColor", json!({"sheet": i, "color": c}));
|
||||
resp.context_menu(|ui| {
|
||||
ui.set_min_width(180.0);
|
||||
if ui.button(app.ui.language.tr("Insert Sheet")).clicked() {
|
||||
app.run_or_alert("home.insertSheet", json!({"before": i}));
|
||||
}
|
||||
if ui.button(app.ui.language.tr("Delete")).clicked() {
|
||||
app.run_or_alert("home.deleteSheet", json!({"sheet": i}));
|
||||
}
|
||||
if ui.button(app.ui.language.tr("Rename")).clicked() {
|
||||
app.grid.renaming_tab = Some(i);
|
||||
app.grid.rename_text = name.clone();
|
||||
}
|
||||
if ui.button(app.ui.language.tr("Move or Copy…")).clicked() {
|
||||
app.open_dialog("moveSheet", json!({"sheet": i}));
|
||||
}
|
||||
if ui.button(app.ui.language.tr("Duplicate")).clicked() {
|
||||
app.run_or_alert("sheet.move", json!({"sheet": i, "to": i + 1, "copy": true}));
|
||||
}
|
||||
ui.menu_button(app.ui.language.tr("Tab Color"), |ui| {
|
||||
let colors = app.session.active().map(|d| d.wb.theme.colors).unwrap_or_default();
|
||||
if let Some(c) = crate::widgets::color_palette_fixed(ui, &colors, app.ui.language.tr("No Color")) {
|
||||
app.run_or_alert("sheet.tabColor", json!({"sheet": i, "color": c}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
if ui.button(app.ui.language.tr("Hide")).clicked() {
|
||||
app.run_or_alert("sheet.hide", json!({"sheet": i}));
|
||||
}
|
||||
if ui.button(app.ui.language.tr("Unhide…")).clicked() {
|
||||
app.run_or_alert("sheet.unhide", json!({}));
|
||||
}
|
||||
ui.separator();
|
||||
let protected = app.active_sheet_protected();
|
||||
if ui
|
||||
.button(if protected {
|
||||
app.ui.language.tr("Unprotect Sheet…")
|
||||
} else {
|
||||
app.ui.language.tr("Protect Sheet…")
|
||||
})
|
||||
.clicked()
|
||||
{
|
||||
app.open_dialog("protectSheet", json!({}));
|
||||
}
|
||||
if ui.button(app.ui.language.tr("Select All Sheets")).clicked() {
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
if ui.button(app.ui.language.tr("Hide")).clicked() {
|
||||
app.run_or_alert("sheet.hide", json!({"sheet": i}));
|
||||
}
|
||||
if ui.button(app.ui.language.tr("Unhide…")).clicked() {
|
||||
app.run_or_alert("sheet.unhide", json!({}));
|
||||
}
|
||||
ui.separator();
|
||||
let protected = app.active_sheet_protected();
|
||||
if ui
|
||||
.button(if protected {
|
||||
app.ui.language.tr("Unprotect Sheet…")
|
||||
} else {
|
||||
app.ui.language.tr("Protect Sheet…")
|
||||
})
|
||||
.clicked()
|
||||
{
|
||||
app.open_dialog("protectSheet", json!({}));
|
||||
}
|
||||
if ui.button(app.ui.language.tr("Select All Sheets")).clicked() {
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
if crate::widgets::icon_button(ui, Icon::Plus, t.text_dim, app.ui.language.tr("New sheet (⇧F11)"), vec2(26.0, 26.0)).clicked() {
|
||||
app.run_or_alert("home.insertSheet", json!({}));
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
if crate::widgets::icon_button(ui, Icon::Plus, t.text_dim, app.ui.language.tr("New sheet (⇧F11)"), vec2(26.0, 26.0)).clicked() {
|
||||
app.run_or_alert("home.insertSheet", json!({}));
|
||||
}
|
||||
}
|
||||
if !view.show_horizontal_scroll {
|
||||
return;
|
||||
}
|
||||
// The track ends where the grid does, so it stops under the vertical scrollbar.
|
||||
let avail = ui.available_rect_before_wrap();
|
||||
@@ -312,8 +330,14 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let split_left =
|
||||
share.map_or(avail.left(), |share| (bar_left + bar_width * share).min(right - SPLIT_WIDTH - MIN_TRACK).max(avail.left()));
|
||||
let split = Rect::from_min_max(pos2(split_left, avail.top()), pos2(split_left + SPLIT_WIDTH, avail.bottom()));
|
||||
tab_split_handle(app, ui, split, (bar_left, bar_width), right, uid, &t);
|
||||
let track = Rect::from_min_max(pos2(split.right(), avail.center().y - 7.0), pos2(right, avail.center().y + 7.0));
|
||||
// Without sheet tabs there is nothing to split: the scroll bar takes the bar.
|
||||
let track_left = if view.show_sheet_tabs {
|
||||
tab_split_handle(app, ui, split, (bar_left, bar_width), right, uid, &t);
|
||||
split.right()
|
||||
} else {
|
||||
avail.left()
|
||||
};
|
||||
let track = Rect::from_min_max(pos2(track_left, avail.center().y - 7.0), pos2(right, avail.center().y + 7.0));
|
||||
crate::grid::horizontal_scrollbar(app, ui, track, &t);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,8 @@ pub struct Tokens {
|
||||
pub header_sel_text: Color32,
|
||||
pub header_all_bg: Color32,
|
||||
pub sel_fill: Color32,
|
||||
/// The tint over the active cell's row (File ▸ Options ▸ General).
|
||||
pub active_row: Color32,
|
||||
pub sel_border: Color32,
|
||||
pub input_bg: Color32,
|
||||
pub input_border: Color32,
|
||||
@@ -217,6 +219,7 @@ impl Tokens {
|
||||
header_sel_text: pick(dark, Color32::from_rgb(0x0B, 0x5E, 0x31), Color32::from_rgb(0x6F, 0xD3, 0x9A)),
|
||||
header_all_bg: pick(dark, Color32::from_rgb(0xC8, 0xE6, 0xD3), Color32::from_rgb(0x2E, 0x55, 0x3E)),
|
||||
sel_fill: pick(dark, Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x1C), Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x24)),
|
||||
active_row: pick(dark, Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x2C), Color32::from_rgba_unmultiplied(0x6F, 0xD3, 0x9A, 0x30)),
|
||||
sel_border: Color32::from_rgb(0x10, 0x7C, 0x41),
|
||||
input_bg: pick(dark, Color32::WHITE, Color32::from_rgb(0x1F, 0x1F, 0x1F)),
|
||||
input_border: pick(dark, Color32::from_rgb(0xD1, 0xD1, 0xD1), Color32::from_rgb(0x50, 0x50, 0x50)),
|
||||
|
||||
@@ -31,6 +31,8 @@ pub struct WindowUi {
|
||||
ribbon_tab: String,
|
||||
/// The status bar's Ready/Enter/Edit, which follows the window's cell draft.
|
||||
mode: gridcraft_engine::Mode,
|
||||
/// A key tip's control request waiting for the frame that draws this window's ribbon.
|
||||
keytip_invoke_next: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl WindowUi {
|
||||
@@ -70,6 +72,7 @@ impl SheetApp {
|
||||
open_switch_windows,
|
||||
ribbon_tab,
|
||||
mode,
|
||||
keytip_invoke_next,
|
||||
} = w;
|
||||
std::mem::swap(&mut self.editor, editor);
|
||||
std::mem::swap(&mut self.text_box_editor, text_box_editor);
|
||||
@@ -86,6 +89,7 @@ impl SheetApp {
|
||||
std::mem::swap(&mut self.open_switch_windows, open_switch_windows);
|
||||
std::mem::swap(&mut self.ui.ribbon_tab, ribbon_tab);
|
||||
std::mem::swap(&mut self.session.mode, mode);
|
||||
std::mem::swap(&mut self.ui.keytip_invoke_next, keytip_invoke_next);
|
||||
}
|
||||
|
||||
/// The window whose interface state is in the per-window fields.
|
||||
@@ -98,6 +102,14 @@ impl SheetApp {
|
||||
/// in (a window never seen before starts fresh). A closed window's state is dropped.
|
||||
pub fn follow_active_window(&mut self) {
|
||||
let active = self.session.active_window();
|
||||
// A window hidden while parked (from another window's View › Hide) isn't drawn either:
|
||||
// its waiting key tip request goes, as it does for a window hidden as it is left.
|
||||
let session = &self.session;
|
||||
for (id, parked) in &mut self.parked {
|
||||
if session.window(*id).is_some_and(|w| w.hidden) {
|
||||
parked.keytip_invoke_next = None;
|
||||
}
|
||||
}
|
||||
if active == self.ui_window {
|
||||
return;
|
||||
}
|
||||
@@ -108,7 +120,12 @@ impl SheetApp {
|
||||
};
|
||||
let mut outgoing = WindowUi::fresh(&self.grid, &self.ui.ribbon_tab);
|
||||
self.swap_window_ui(&mut outgoing);
|
||||
if self.session.window(previous).is_some() {
|
||||
if let Some(window) = self.session.window(previous) {
|
||||
// A hidden window isn't drawn: a key tip request waiting for its next frame would
|
||||
// fire whenever it is shown again, so it goes.
|
||||
if window.hidden {
|
||||
outgoing.keytip_invoke_next = None;
|
||||
}
|
||||
self.parked.insert(previous, outgoing);
|
||||
}
|
||||
if let Some(mut incoming) = active.and_then(|w| self.parked.remove(&w)) {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! "Highlight the active cell's row": a global preference, off by default, that tints the whole
|
||||
//! row of the active cell and leaves the workbook alone.
|
||||
|
||||
use egui::{Shape, vec2};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::CellRef;
|
||||
use gridcraft_ui_egui::theme::Tokens;
|
||||
use gridcraft_ui_egui::{SheetApp, UiState, grid::Geo, grid::active_row_band};
|
||||
use serde_json::json;
|
||||
|
||||
fn harness(session: Session) -> egui_kittest::Harness<'static, SheetApp> {
|
||||
let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
crate::common::app(session),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn book(active: &str) -> Session {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("selection.set", json!({"range": active})).unwrap();
|
||||
session
|
||||
}
|
||||
|
||||
/// The rectangles painted with the highlight's own colour.
|
||||
fn tinted(h: &egui_kittest::Harness<'_, SheetApp>) -> Vec<egui::Rect> {
|
||||
let tint = Tokens::get(&h.ctx).active_row;
|
||||
h.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|clipped| match &clipped.shape {
|
||||
Shape::Rect(r) if r.fill == tint => Some(r.rect),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_is_off_by_default_and_paints_nothing() {
|
||||
assert!(!UiState::default().highlight_active_row);
|
||||
let h = harness(book("C5"));
|
||||
assert!(tinted(&h).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turned_on_it_tints_the_active_row_across_the_columns() {
|
||||
let mut h = harness(book("C5"));
|
||||
assert_eq!(h.state_mut().run("options.highlightActiveRow", json!({"on": true})), Ok(json!({"on": true})));
|
||||
h.run_steps(2);
|
||||
let bands = tinted(&h);
|
||||
assert_eq!(bands.len(), 1, "{bands:?}");
|
||||
let app = h.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
|
||||
let cell = geo.cell_rect(sheet, CellRef::parse("C5").unwrap());
|
||||
assert_eq!((bands[0].top(), bands[0].bottom()), (cell.top(), cell.bottom()));
|
||||
assert!(bands[0].left() <= geo.cells.left() + 1.0, "starts at the first column: {:?}", bands[0]);
|
||||
assert!(bands[0].right() >= geo.cells.right() - 1.0, "reaches the last visible column: {:?}", bands[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_band_follows_the_active_cell_and_only_the_row() {
|
||||
let mut h = harness(book("C5"));
|
||||
h.state_mut().run("options.highlightActiveRow", json!({"on": true})).unwrap();
|
||||
h.state_mut().session.execute("selection.set", json!({"range": "E9"})).unwrap();
|
||||
h.run_steps(2);
|
||||
let bands = tinted(&h);
|
||||
assert_eq!(bands.len(), 1, "a row, and no column band: {bands:?}");
|
||||
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);
|
||||
assert_eq!(bands[0].top(), geo.cell_rect(sheet, CellRef::parse("E9").unwrap()).top());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_applies_to_every_sheet_and_workbook() {
|
||||
let mut h = harness(book("C5"));
|
||||
h.state_mut().run("options.highlightActiveRow", json!({"on": true})).unwrap();
|
||||
h.state_mut().session.execute("home.insertSheet", json!({"name": "Other"})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(tinted(&h).len(), 1, "another sheet");
|
||||
h.state_mut().session.new_workbook();
|
||||
h.run_steps(2);
|
||||
assert_eq!(tinted(&h).len(), 1, "another workbook");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turning_it_off_clears_the_band_and_the_toggle_leaves_the_workbook_alone() {
|
||||
let mut h = harness(book("C5"));
|
||||
let dirty = h.state().session.active().unwrap().is_dirty();
|
||||
assert_eq!(h.state_mut().run("options.highlightActiveRow", json!({})), Ok(json!({"on": true})), "no parameters: it toggles");
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.active().unwrap().is_dirty(), dirty);
|
||||
assert_eq!(h.state_mut().run("options.highlightActiveRow", json!({})), Ok(json!({"on": false})));
|
||||
h.run_steps(2);
|
||||
assert!(tinted(&h).is_empty());
|
||||
assert_eq!(h.state().session.active().unwrap().is_dirty(), dirty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preference_survives_a_restart_and_older_settings_read_as_off() {
|
||||
let state = UiState { highlight_active_row: true, ..UiState::default() };
|
||||
let saved = serde_json::to_string(&state).unwrap();
|
||||
assert!(serde_json::from_str::<UiState>(&saved).unwrap().highlight_active_row);
|
||||
assert!(!serde_json::from_str::<UiState>("{}").unwrap().highlight_active_row);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_row_or_a_row_outside_the_quadrant_has_no_band() {
|
||||
let mut session = book("A1");
|
||||
session.execute("home.rowHeight", json!({"rows": "5:5", "height": 0})).unwrap();
|
||||
let h = harness(session);
|
||||
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);
|
||||
assert_eq!(active_row_band(&geo, sheet, 4, (0, 20), (0, 5)), None, "hidden");
|
||||
assert_eq!(active_row_band(&geo, sheet, 30, (0, 20), (0, 5)), None, "outside");
|
||||
assert!(active_row_band(&geo, sheet, 6, (0, 20), (0, 5)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_page_layout_the_band_shows_on_a_click_to_add_data_page_too() {
|
||||
let mut h = harness(book("C5"));
|
||||
h.state_mut().run("options.highlightActiveRow", json!({"on": true})).unwrap();
|
||||
h.state_mut().session.execute("view.pageLayout", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
let bands = tinted(&h);
|
||||
// Each page paints its own part of the row; the faded page paints it again over the fade.
|
||||
let repeated = bands.iter().filter(|b| bands.iter().filter(|o| o == b).count() == 2).count();
|
||||
assert_eq!(repeated, 2, "{bands:?}");
|
||||
assert!(bands.iter().all(|b| b.top() == bands[0].top()), "one row: {bands:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_frozen_panes_the_band_crosses_the_frozen_columns() {
|
||||
let mut h = harness(book("C5"));
|
||||
h.state_mut().session.execute("selection.set", json!({"range": "C3"})).unwrap();
|
||||
h.state_mut().session.execute("view.freezePanes", json!({})).unwrap();
|
||||
h.state_mut().session.execute("selection.set", json!({"range": "E7"})).unwrap();
|
||||
h.state_mut().run("options.highlightActiveRow", json!({"on": true})).unwrap();
|
||||
h.run_steps(2);
|
||||
let bands = tinted(&h);
|
||||
assert_eq!(bands.len(), 2, "the frozen columns' pane and the scrolling one: {bands:?}");
|
||||
assert_eq!(bands[0].top(), bands[1].top());
|
||||
}
|
||||
@@ -160,3 +160,40 @@ fn merged_range_draws_the_outline_from_its_edge_cells() {
|
||||
assert_eq!(ys.iter().filter(|y| (**y - max_y).abs() < 0.5).count(), 4, "bottom edge");
|
||||
assert!(max_x - min_x > 100.0 && max_y - min_y > 40.0, "the outline spans the whole merged area");
|
||||
}
|
||||
|
||||
/// #2544: a merged range in frozen columns keeps its whole outline while the others scroll: the
|
||||
/// bottom edge under its last column ends at that column's own right edge, the freeze line.
|
||||
#[test]
|
||||
fn frozen_merged_range_keeps_its_outline_when_scrolled() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("home.borders", json!({"range": "A2:B3", "preset": "thickOutside", "color": "#C81E1E"})).unwrap();
|
||||
h.state_mut().run("home.mergeCells", json!({"range": "A2:B3"})).unwrap();
|
||||
h.state_mut().run("view.freezePanes", json!({"cell": "C1"})).unwrap();
|
||||
h.state_mut().run("selection.set", json!({"range": "H10"})).unwrap();
|
||||
h.state_mut().view_mut().unwrap().scroll.x = 300.0;
|
||||
h.run_steps(6);
|
||||
let freeze_x = {
|
||||
let app = h.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let geo = gridcraft_ui_egui::grid::Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll);
|
||||
geo.cells.left() + geo.frozen_w
|
||||
};
|
||||
let red = Color32::from_rgb(0xC8, 0x1E, 0x1E);
|
||||
let right_ends: Vec<f32> = h
|
||||
.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| match &shape.shape {
|
||||
Shape::LineSegment { points, stroke } if stroke.color == red && (points[0].y - points[1].y).abs() < 0.5 => {
|
||||
Some(points[0].x.max(points[1].x))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(right_ends.len(), 4, "top and bottom edges, one segment per column: {right_ends:?}");
|
||||
assert_eq!(
|
||||
right_ends.iter().filter(|x| (**x - freeze_x).abs() < 1.0).count(),
|
||||
2,
|
||||
"B's segments end at the freeze line: {right_ends:?} vs {freeze_x}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -482,3 +482,80 @@ fn the_cut_text_pastes_again_once_the_clipboard_has_held_other_text() {
|
||||
h.run_steps(2);
|
||||
assert_eq!(value_at(&h, "H1"), Some(Value::from("Name")), "the same text copied afresh pastes");
|
||||
}
|
||||
|
||||
/// The floating pictures on the active sheet: (anchor cell, width, height, mime).
|
||||
fn pictures(h: &Harness) -> Vec<(String, f32, f32, String)> {
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
sh.images.iter().map(|i| (i.anchor.cell.a1(), i.anchor.width, i.anchor.height, i.mime.clone())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_v_with_a_picture_on_the_clipboard_inserts_it_at_the_active_cell() {
|
||||
// A screenshot or a browser image: the clipboard has pixels and no text (#699, #3018, #3182).
|
||||
let mut h = harness(Services::default());
|
||||
h.state_mut().run("selection.set", json!({"cell": "C5"})).unwrap();
|
||||
let image = egui::ColorImage::new([4, 3], vec![egui::Color32::from_rgb(200, 30, 30); 12]);
|
||||
h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(image)));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().message.is_none(), "{:?}", h.state().message);
|
||||
assert_eq!(pictures(&h), vec![("C5".to_string(), 4.0, 3.0, "image/png".to_string())]);
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
let png = image::load_from_memory(&sh.images[0].data).unwrap().to_rgba8();
|
||||
assert_eq!(png.get_pixel(3, 2).0, [200, 30, 30, 255], "the pixels survive the PNG round trip");
|
||||
|
||||
// One undo step removes it.
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
assert!(pictures(&h).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_clipboard_picture_is_refused_without_inserting() {
|
||||
let mut h = harness(Services::default());
|
||||
let mut image = egui::ColorImage::new([4, 3], vec![egui::Color32::WHITE; 12]);
|
||||
image.pixels.truncate(5);
|
||||
h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(image)));
|
||||
h.run_steps(2);
|
||||
assert!(pictures(&h).is_empty());
|
||||
assert!(h.state().message.is_some(), "the user is told the paste failed");
|
||||
|
||||
dismiss(&mut h);
|
||||
h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(egui::ColorImage::new([0, 0], Vec::new()))));
|
||||
h.run_steps(2);
|
||||
assert!(pictures(&h).is_empty());
|
||||
|
||||
// Wider than the 16384-pixel limit: refused before its pixels are copied.
|
||||
dismiss(&mut h);
|
||||
let wide = egui::ColorImage::new([20_000, 1], vec![egui::Color32::WHITE; 20_000]);
|
||||
h.input_mut().events.push(Event::PasteImage(std::sync::Arc::new(wide)));
|
||||
h.run_steps(2);
|
||||
assert!(pictures(&h).is_empty());
|
||||
let message = format!("{:?}", h.state().message);
|
||||
assert!(message.contains("too large"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ribbon_paste_pastes_a_clipboard_picture_when_there_is_no_text() {
|
||||
let reads = Rc::new(RefCell::new(0));
|
||||
let count = reads.clone();
|
||||
let (_, mut services) = system_clipboard("");
|
||||
services.paste_image = Some(Box::new(move || {
|
||||
*count.borrow_mut() += 1;
|
||||
Some(image::RgbaImage::from_pixel(5, 2, image::Rgba([0, 0, 255, 255])))
|
||||
}));
|
||||
let mut h = harness(services);
|
||||
h.state_mut().run("selection.set", json!({"cell": "B7"})).unwrap();
|
||||
click_paste(&mut h);
|
||||
assert_eq!(*reads.borrow(), 1);
|
||||
assert_eq!(pictures(&h), vec![("B7".to_string(), 5.0, 2.0, "image/png".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ribbon_paste_prefers_clipboard_text_over_a_picture() {
|
||||
let (_, mut services) = system_clipboard("words");
|
||||
services.paste_image = Some(Box::new(|| Some(image::RgbaImage::new(1, 1))));
|
||||
let mut h = harness(services);
|
||||
h.state_mut().run("selection.set", json!({"cell": "D2"})).unwrap();
|
||||
click_paste(&mut h);
|
||||
assert_eq!(value_at(&h, "D2"), Some(Value::from("words")));
|
||||
assert!(pictures(&h).is_empty());
|
||||
}
|
||||
|
||||
@@ -100,3 +100,36 @@ fn a_quick_right_click_between_pixels_keeps_the_menu_open_on_retina() {
|
||||
h.run_steps(4);
|
||||
assert!(h.state().grid.context_menu.is_some(), "the menu stays open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_cell_offers_to_edit_open_and_remove_its_link() {
|
||||
// #2120: the menu only offered "Link…", and its dialog was titled Insert Hyperlink.
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
let mut h = harness();
|
||||
h.state_mut().session.execute("insert.link", serde_json::json!({"cell": "B2", "target": "#Sheet1!D9", "text": "Go"})).unwrap();
|
||||
h.run_steps(2);
|
||||
quick_right_click(&mut h, "B2");
|
||||
assert!(h.query_by_label("Edit Hyperlink…").is_some() && h.query_by_label("Open Hyperlink").is_some(), "linked cell menu");
|
||||
assert!(h.query_by_label("Link…").is_none());
|
||||
// Open Hyperlink follows it (an internal reference selects its cell).
|
||||
h.get_by_label("Open Hyperlink").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::parse("D9").unwrap());
|
||||
// Edit Hyperlink opens the link's own dialog.
|
||||
quick_right_click(&mut h, "B2");
|
||||
h.get_by_label("Edit Hyperlink…").click();
|
||||
h.run_steps(4);
|
||||
let d = h.state().dialog.as_ref().expect("the link dialog");
|
||||
assert_eq!(d.title, "Edit Hyperlink");
|
||||
assert_eq!(d.values.get("target").and_then(|v| v.as_str()), Some("#Sheet1!D9"));
|
||||
h.state_mut().dialog = None;
|
||||
h.run_steps(2);
|
||||
// Remove Hyperlink takes the link away; a cell without one offers Link… again.
|
||||
quick_right_click(&mut h, "B2");
|
||||
h.get_by_label("Remove Hyperlink").click();
|
||||
h.run_steps(4);
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap().clone();
|
||||
assert!(!sh.hyperlinks.contains_key(&CellRef::parse("B2").unwrap()));
|
||||
quick_right_click(&mut h, "B2");
|
||||
assert!(h.query_by_label("Link…").is_some() && h.query_by_label("Edit Hyperlink…").is_none());
|
||||
}
|
||||
|
||||
@@ -172,6 +172,33 @@ fn moving_a_selection_keeps_move_and_still_moves_cells() {
|
||||
assert_eq!(sh.value(CellRef::parse("E5").unwrap()), Value::Number(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_selection_onto_data_asks_before_replacing_it() {
|
||||
// #3136: the drop replaced the cells there without asking.
|
||||
let mut h = harness();
|
||||
h.state_mut().session.execute("selection.set", json!({"range": "A2:B2"})).unwrap();
|
||||
h.run_steps(2);
|
||||
let r = selection_rect(&h);
|
||||
press(&mut h, pos2(r.left() + 10.0, r.top() + 1.0));
|
||||
let target = cell(&h, "A1").center();
|
||||
move_with_cursor(&mut h, target, CursorIcon::Move);
|
||||
button(&mut h, target, false);
|
||||
h.run_steps(1);
|
||||
let q = h.state().macros.question.clone().expect("a question before replacing A1:B1");
|
||||
assert_eq!(q.text, "There's already data here. Do you want to replace it?");
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.value(CellRef::parse("A1").unwrap()), Value::text("A"));
|
||||
assert_eq!(sh.value(CellRef::parse("A2").unwrap()), Value::Number(1.0));
|
||||
// OK replaces them.
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().macros.question.is_none());
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.value(CellRef::parse("A1").unwrap()), Value::Number(1.0));
|
||||
assert_eq!(sh.value(CellRef::parse("B1").unwrap()), Value::Number(2.0));
|
||||
assert_eq!(sh.value(CellRef::parse("A2").unwrap()), Value::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_headers_keeps_the_axis_cursor_when_leaving_the_header() {
|
||||
for cols in [true, false] {
|
||||
|
||||
@@ -34,3 +34,27 @@ fn typing_right_after_ctrl_f_fills_find_what() {
|
||||
fn typing_right_after_ctrl_h_fills_find_what() {
|
||||
assert_eq!(type_after_shortcut(Key::H), Some(json!("abc")));
|
||||
}
|
||||
|
||||
/// With a range selected, Find All in the dialog lists only the matches inside it, as in Excel
|
||||
/// (#3224); with one cell selected it searches the whole sheet.
|
||||
#[test]
|
||||
fn find_all_with_a_range_selected_searches_only_the_range() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
for (range, expected) in [("B1:B3", 2), ("C5", 4)] {
|
||||
let mut h = harness();
|
||||
let app = h.state_mut();
|
||||
app.session.execute("range.setValues", json!({"range": "A1:B3", "values": [["pear", "pear"], ["fig", "fig"], ["pear", "pear"]]})).unwrap();
|
||||
app.session.execute("selection.set", json!({"range": range})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.push(Event::Key { key: Key::F, physical_key: Some(Key::F), pressed: true, repeat: false, modifiers: CTRL });
|
||||
h.input_mut().events.push(Event::Key { key: Key::F, physical_key: Some(Key::F), pressed: false, repeat: false, modifiers: CTRL });
|
||||
h.run_steps(6);
|
||||
h.input_mut().events.push(Event::Text("pear".into()));
|
||||
h.run_steps(4);
|
||||
h.get_by_label("Find All").click();
|
||||
h.run_steps(4);
|
||||
let d = h.state().dialog.as_ref().expect("find dialog is open");
|
||||
let count = d.result.as_ref().map(|r| r["count"].clone());
|
||||
assert_eq!(count, Some(json!(expected)), "{range} selected");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Home ▸ Font Size is a box you type a size into, with the standard sizes behind its arrow (#932).
|
||||
//! Home ▸ Font Size is a box you type a size into, with the standard sizes behind its arrow (#932),
|
||||
//! and the size sits in the middle of that box (#3155).
|
||||
|
||||
use egui::{Event, Key, Modifiers, accesskit::Role};
|
||||
use egui::{Event, Key, Modifiers, Rect, Shape, accesskit::Role};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::{CellRef, Value};
|
||||
@@ -124,3 +125,31 @@ fn the_arrow_opens_the_plain_list_of_sizes() {
|
||||
h.run_steps(3);
|
||||
assert_eq!(size(&h, "A1"), 12.0, "Down from 11 then Enter picks 12");
|
||||
}
|
||||
|
||||
/// The on-screen box of the painted text `text` that overlaps `within`. The grid's row headers
|
||||
/// paint "11" too, so the size has to be looked for inside its own box.
|
||||
fn painted_text(h: &Harness, text: &str, within: Rect) -> Rect {
|
||||
h.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|s| match &s.shape {
|
||||
Shape::Text(t) if t.galley.job.text == text => {
|
||||
let rect = t.galley.rect.translate(t.pos.to_vec2());
|
||||
within.intersects(rect).then_some(rect)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("{text:?} is painted inside {within:?}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A text edit aligns its text to the top of its rect unless told otherwise, which left the size
|
||||
/// 4 pt above the Font name's baseline.
|
||||
fn the_size_is_vertically_centered_in_its_box() {
|
||||
let h = harness();
|
||||
let field = h.get_by_role_and_label(Role::TextInput, "Font Size").rect();
|
||||
let value = painted_text(&h, "11", field);
|
||||
let name = painted_text(&h, "Calibri", Rect::EVERYTHING);
|
||||
assert!((value.center().y - field.center().y).abs() <= 1.0, "the size is centered in its box: {value:?} in {field:?}");
|
||||
assert!((value.center().y - name.center().y).abs() <= 1.0, "the size is on the Font name's baseline: {value:?} against {name:?}");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Ctrl+Shift+U (Control+Shift+U in Excel for Mac) expands and collapses the formula bar.
|
||||
|
||||
use egui::{Event, Key, Modifiers};
|
||||
use egui::{Event, Key, Modifiers, vec2};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use gridcraft_ui_egui::keymap::{Shortcut, lookup};
|
||||
@@ -84,3 +84,80 @@ fn expand_formula_bar_command_sets_or_toggles_the_state() {
|
||||
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({})), Ok(json!({"on": false})));
|
||||
assert!(!expanded(&h));
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness, from: egui::Pos2, to: egui::Pos2) {
|
||||
use egui::PointerButton;
|
||||
h.input_mut().events.extend([
|
||||
Event::PointerMoved(from),
|
||||
Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
h.step();
|
||||
for i in 1..=6 {
|
||||
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 6.0)));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(20);
|
||||
}
|
||||
|
||||
fn bar(h: &Harness) -> gridcraft_ui_egui::formula_bar::BarLayout {
|
||||
gridcraft_ui_egui::formula_bar::BarLayout::get(&h.ctx)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_bottom_edge_sets_the_height_that_expanding_returns_to() {
|
||||
let mut h = harness();
|
||||
h.run_steps(20);
|
||||
let start = bar(&h);
|
||||
assert_eq!(start.visible_lines, 1, "one line of text shows a one-line bar");
|
||||
assert!(
|
||||
start.handle.height() > 0.0 && (start.handle.bottom() - start.field.bottom()).abs() <= 4.0,
|
||||
"the handle is the bar's bottom edge: {start:?}"
|
||||
);
|
||||
// Drag the edge down about five and a half rows: the bar snaps to whole lines.
|
||||
let row = 17.0;
|
||||
drag(&mut h, start.handle.center(), start.handle.center() + vec2(0.0, 5.5 * row));
|
||||
let lines = h.state().ui.formula_bar_lines;
|
||||
assert!(expanded(&h), "a taller bar is expanded");
|
||||
assert!((6..=7).contains(&lines), "about six more lines: {lines}");
|
||||
assert_eq!(bar(&h).visible_lines, lines, "the field shows the dragged lines: {:?}", bar(&h));
|
||||
// Collapse, then expand: back to the dragged height, not the default five lines.
|
||||
ctrl_shift_u(&mut h, CTRL_SHIFT);
|
||||
h.run_steps(20);
|
||||
assert!(!expanded(&h));
|
||||
assert_eq!(bar(&h).visible_lines, 1);
|
||||
ctrl_shift_u(&mut h, CTRL_SHIFT);
|
||||
h.run_steps(20);
|
||||
assert_eq!(bar(&h).visible_lines, lines, "expanding returns to the dragged height");
|
||||
// Dragging the edge up to one line collapses the bar and keeps the expanded height.
|
||||
let edge = bar(&h).handle.center();
|
||||
drag(&mut h, edge, edge - vec2(0.0, 20.0 * row));
|
||||
assert!(!expanded(&h), "a one-line bar is collapsed");
|
||||
assert_eq!(bar(&h).visible_lines, 1);
|
||||
assert_eq!(h.state().ui.formula_bar_lines, lines);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_formula_bar_with_lines_sets_the_expanded_height() {
|
||||
let mut h = harness();
|
||||
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": 8})), Ok(json!({"on": true, "lines": 8})));
|
||||
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": 1})), Ok(json!({"on": false, "lines": 8})), "one line collapses");
|
||||
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({})), Ok(json!({"on": true})));
|
||||
assert_eq!(h.state().ui.formula_bar_lines, 8, "expanding returns to the set height");
|
||||
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": 0})), Ok(json!({"on": false, "lines": 8})), "zero lines is one line");
|
||||
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"lines": u64::MAX})), Ok(json!({"on": true, "lines": 100})), "capped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expanded_formula_bar_leaves_room_for_the_worksheet_in_a_short_window() {
|
||||
// At 400 pt the title bar and ribbon leave about 236 pt: half the window as a formula bar
|
||||
// would leave the status bar and sheet tabs no room for any of the grid.
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = crate::common::harness_with(crate::common::builder().with_size(vec2(1200.0, 400.0)), crate::common::app(session), 4);
|
||||
h.state_mut().run("view.expandFormulaBar", json!({"lines": 100})).unwrap();
|
||||
h.run_steps(60);
|
||||
let cells = h.state().grid.cells_rect.expect("the grid is drawn");
|
||||
assert!(cells.height() >= 40.0, "a couple of rows stay visible under a {} pt bar: {cells:?}", bar(&h).height);
|
||||
assert!(bar(&h).visible_lines > 1, "the bar is still expanded");
|
||||
}
|
||||
|
||||
@@ -173,3 +173,148 @@ fn shift_does_not_extend_a_row_reference_across_sheets() {
|
||||
[("6:6".into(), Some("Other sheet".into())),]
|
||||
);
|
||||
}
|
||||
|
||||
fn press_and_drag(h: &mut egui_kittest::Harness<'_, SheetApp>, from: Pos2, to: Pos2, modifiers: Modifiers) {
|
||||
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
|
||||
h.input_mut()
|
||||
.events
|
||||
.extend([Event::PointerMoved(from), Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers }]);
|
||||
h.step();
|
||||
drag_to(h, to);
|
||||
}
|
||||
|
||||
fn drag_to(h: &mut egui_kittest::Harness<'_, SheetApp>, to: Pos2) {
|
||||
h.input_mut().events.push(Event::PointerMoved(to));
|
||||
h.step();
|
||||
}
|
||||
|
||||
fn release(h: &mut egui_kittest::Harness<'_, SheetApp>, at: Pos2) {
|
||||
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
|
||||
}
|
||||
|
||||
fn text<'a>(h: &'a egui_kittest::Harness<'_, SheetApp>) -> &'a str {
|
||||
h.state().editor.as_ref().map_or("", |ed| ed.text.as_str())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_over_column_headers_extends_the_reference() {
|
||||
let mut h = harness();
|
||||
let (c, e, a, d) = (header_center(&h, false, 2), header_center(&h, false, 4), header_center(&h, false, 0), header_center(&h, false, 3));
|
||||
press_and_drag(&mut h, c, e, Modifiers::NONE);
|
||||
assert_eq!(text(&h), "=SUM(C:E");
|
||||
// Back past the press: the reference follows the pointer instead of keeping E.
|
||||
drag_to(&mut h, a);
|
||||
assert_eq!(text(&h), "=SUM(A:C");
|
||||
// The release position counts even without a movement frame before it.
|
||||
release(&mut h, d);
|
||||
assert_eq!(text(&h), "=SUM(C:D");
|
||||
assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "F8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_over_row_headers_extends_the_reference() {
|
||||
let mut h = harness();
|
||||
let (r3, r6, r2) = (header_center(&h, true, 2), header_center(&h, true, 5), header_center(&h, true, 1));
|
||||
press_and_drag(&mut h, r3, r6, Modifiers::NONE);
|
||||
assert_eq!(text(&h), "=SUM(3:6");
|
||||
release(&mut h, r2);
|
||||
assert_eq!(text(&h), "=SUM(2:3");
|
||||
assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "F8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_drag_on_another_sheet_keeps_the_qualifier() {
|
||||
let mut h = harness();
|
||||
h.state_mut().session.execute("home.insertSheet", json!({"name": "Other sheet"})).unwrap();
|
||||
h.run_steps(2);
|
||||
let (b, d) = (header_center(&h, false, 1), header_center(&h, false, 3));
|
||||
press_and_drag(&mut h, b, d, Modifiers::NONE);
|
||||
assert_eq!(text(&h), "=SUM('Other sheet'!B:D");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_drag_with_command_keeps_the_earlier_argument() {
|
||||
let mut h = harness();
|
||||
let (a, e, f) = (header_center(&h, false, 0), header_center(&h, false, 4), header_center(&h, false, 5));
|
||||
click(&mut h, a);
|
||||
press_and_drag(&mut h, e, f, Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE });
|
||||
assert_eq!(text(&h), "=SUM(A:A,E:F");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_drag_with_shift_keeps_the_far_end_of_the_reference() {
|
||||
let mut h = harness();
|
||||
let (c, e, d) = (header_center(&h, false, 2), header_center(&h, false, 4), header_center(&h, false, 3));
|
||||
click(&mut h, c);
|
||||
press_and_drag(&mut h, e, d, Modifiers { shift: true, ..Modifiers::NONE });
|
||||
assert_eq!(text(&h), "=SUM(C:D");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_on_headers_replace_the_reference_after_a_drag_too() {
|
||||
let mut h = harness();
|
||||
let (c, e, g) = (header_center(&h, false, 2), header_center(&h, false, 4), header_center(&h, false, 6));
|
||||
press_and_drag(&mut h, c, e, Modifiers::NONE);
|
||||
release(&mut h, e);
|
||||
assert_eq!(text(&h), "=SUM(C:E");
|
||||
click(&mut h, g);
|
||||
assert_eq!(text(&h), "=SUM(G:G");
|
||||
}
|
||||
|
||||
fn shift_arrow(h: &mut egui_kittest::Harness<'_, SheetApp>, key: egui::Key) {
|
||||
let modifiers = Modifiers { shift: true, ..Modifiers::NONE };
|
||||
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
|
||||
h.step();
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers });
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_arrow_after_a_header_drag_moves_the_dragged_end() {
|
||||
let mut h = harness();
|
||||
let (c, e) = (header_center(&h, false, 2), header_center(&h, false, 4));
|
||||
press_and_drag(&mut h, c, e, Modifiers::NONE);
|
||||
release(&mut h, e);
|
||||
assert_eq!(text(&h), "=SUM(C:E");
|
||||
shift_arrow(&mut h, egui::Key::ArrowRight);
|
||||
assert_eq!(text(&h), "=SUM(C:F");
|
||||
|
||||
// Dragged leftwards, the dragged end is the first column.
|
||||
let mut h = harness();
|
||||
let (e, c) = (header_center(&h, false, 4), header_center(&h, false, 2));
|
||||
press_and_drag(&mut h, e, c, Modifiers::NONE);
|
||||
release(&mut h, c);
|
||||
assert_eq!(text(&h), "=SUM(C:E");
|
||||
shift_arrow(&mut h, egui::Key::ArrowLeft);
|
||||
assert_eq!(text(&h), "=SUM(B:E");
|
||||
|
||||
let mut h = harness();
|
||||
let (r1, r3) = (header_center(&h, true, 0), header_center(&h, true, 2));
|
||||
press_and_drag(&mut h, r1, r3, Modifiers::NONE);
|
||||
release(&mut h, r3);
|
||||
assert_eq!(text(&h), "=SUM(1:3");
|
||||
shift_arrow(&mut h, egui::Key::ArrowDown);
|
||||
assert_eq!(text(&h), "=SUM(1:4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_arrow_pointing_on_another_sheet_keeps_the_qualifier() {
|
||||
let mut h = harness();
|
||||
h.state_mut().session.execute("home.insertSheet", json!({"name": "Other sheet"})).unwrap();
|
||||
h.run_steps(2);
|
||||
let (b, d) = (header_center(&h, false, 1), header_center(&h, false, 3));
|
||||
press_and_drag(&mut h, b, d, Modifiers::NONE);
|
||||
release(&mut h, d);
|
||||
assert_eq!(text(&h), "=SUM('Other sheet'!B:D");
|
||||
let app = h.state_mut();
|
||||
let mut ed = app.editor.take().unwrap();
|
||||
gridcraft_ui_egui::grid::point_move(app, &mut ed, 0, 1, true, false);
|
||||
app.editor = Some(ed);
|
||||
assert_eq!(text(&h), "=SUM('Other sheet'!B:E");
|
||||
}
|
||||
|
||||
@@ -66,3 +66,36 @@ fn dragging_the_handles_freezes_moves_and_unfreezes() {
|
||||
drag(&mut h, rows.center(), pos2(rows.center().x, g.cells.top() - 5.0));
|
||||
assert_eq!(freeze(&h), None);
|
||||
}
|
||||
|
||||
/// The last frozen column ends at the freeze line however far the other columns scroll, while
|
||||
/// the next column's left edge moves with the scroll (#2544).
|
||||
#[test]
|
||||
fn last_frozen_column_ends_at_the_freeze_line_when_scrolled() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("view.freezePanes", serde_json::json!({"cell": "C3"})).unwrap();
|
||||
h.state_mut().view_mut().unwrap().scroll = egui::vec2(300.0, 200.0);
|
||||
h.run_steps(2);
|
||||
let g = geo(&h);
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap().clone();
|
||||
let split = g.cells.min + egui::vec2(g.frozen_w, g.frozen_h);
|
||||
assert!((g.x_end(&sh, 1) - split.x).abs() < 0.01 && (g.y_end(&sh, 1) - split.y).abs() < 0.01);
|
||||
assert!(g.x(&sh, 2) < split.x && g.y(&sh, 2) < split.y, "C and row 3 are scrolled under the frozen panes");
|
||||
assert!(g.same_col_pane(0, 1) && g.same_col_pane(2, 9) && !g.same_col_pane(1, 2));
|
||||
}
|
||||
|
||||
/// Dropping the column handle on the left part of the last frozen column while scrolled freezes
|
||||
/// up to its left edge: the column's right edge is the freeze line, not the next column's
|
||||
/// scrolled position (#2544).
|
||||
#[test]
|
||||
fn dragging_the_column_handle_when_scrolled_measures_the_last_frozen_column() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("view.freezePanes", serde_json::json!({"cell": "C1"})).unwrap();
|
||||
h.state_mut().view_mut().unwrap().scroll = egui::vec2(300.0, 0.0);
|
||||
h.run_steps(2);
|
||||
let g = geo(&h);
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap().clone();
|
||||
let (cols, _) = freeze_handles(&g);
|
||||
assert!(g.x(&sh, 2) < g.x(&sh, 1), "C is scrolled left of B");
|
||||
drag(&mut h, cols.center(), pos2(g.x(&sh, 1) + 10.0, g.cells.top() + 40.0));
|
||||
assert_eq!(freeze(&h), Some((0, 1)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Committed ink is painted as the pen previews it, at any zoom.
|
||||
|
||||
use egui::Shape;
|
||||
use egui::epaint::ColorMode;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
/// The colour of the ink stroke drawn from (40, 40) to (200, 40) sheet points, `width` points
|
||||
/// wide: the two-point horizontal path whose width is `width` at its own scale (160 pt long).
|
||||
fn painted_ink(h: &egui_kittest::Harness<'static, SheetApp>, width: f32) -> Option<egui::Color32> {
|
||||
h.output().shapes.iter().find_map(|s| match &s.shape {
|
||||
Shape::Path(p) if p.points.len() == 2 && (p.points[0].y - p.points[1].y).abs() < 0.01 => {
|
||||
let z = (p.points[1].x - p.points[0].x).abs() / 160.0;
|
||||
match p.stroke.color {
|
||||
ColorMode::Solid(c) if z > 0.0 && (p.stroke.width - width * z).abs() < 0.01 => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ink_translucency_follows_the_pen_not_the_zoom() {
|
||||
// A highlighter (over 8 pt) is translucent and a pen isn't, whatever the zoom: the committed
|
||||
// stroke used its width on screen, so at 50% a 12 pt highlighter turned opaque and at 200% a
|
||||
// 6 pt pen turned translucent, unlike the pen's preview.
|
||||
for (width, zoom, translucent) in [(12.0_f32, 50, true), (6.0, 200, false), (12.0, 100, true), (6.0, 100, false)] {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("view.zoom", json!({"percent": zoom})).unwrap();
|
||||
s.execute("draw.stroke", json!({"points": [[40, 40], [200, 40]], "color": "#FFFF00", "width": width})).unwrap();
|
||||
let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
crate::common::app(s),
|
||||
);
|
||||
h.run_steps(4);
|
||||
let c = painted_ink(&h, width).unwrap_or_else(|| panic!("the {width} pt stroke is painted at {zoom}%"));
|
||||
assert_eq!(c.a() < 255, translucent, "{width} pt at {zoom}%: {c:?}");
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,11 @@ fn windows_and_mac_variants() {
|
||||
assert_eq!(lookup(Key::ArrowLeft, Some(Key::Num4), alt_shift, false), run("data.ungroup"));
|
||||
assert_eq!(lookup(Key::PageDown, Some(Key::Num3), Modifiers::ALT, false), Some(Shortcut::ScreenRight));
|
||||
assert_eq!(lookup(Key::PageDown, Some(Key::Num3), CTRL_SHIFT, false), None);
|
||||
// Ctrl+. is Next Corner of Selection, with Control on a Mac too (#1442); Cmd+Shift+. stays
|
||||
// Increase Font Size.
|
||||
assert_eq!(lookup(Key::Period, Some(Key::Period), cmd, false), run("selection.nextCorner"));
|
||||
assert_eq!(lookup(Key::Period, Some(Key::Period), ctrl, true), run("selection.nextCorner"));
|
||||
assert_eq!(lookup(Key::Period, Some(Key::Period), Modifiers { shift: true, ..mac_cmd }, true), run("home.increaseFontSize"));
|
||||
// Plain keys stay with the grid (typing, navigation).
|
||||
assert_eq!(lookup(Key::A, Some(Key::A), Modifiers::NONE, false), None);
|
||||
assert_eq!(lookup(Key::F2, Some(Key::F2), Modifiers::NONE, false), None);
|
||||
|
||||
@@ -2754,6 +2754,308 @@ fn keytip_fp_() {
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "file.print"), "FP must journal file.print");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_ir_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "IR", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.insertRows"), "IR must journal home.insertRows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_ic_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "IC", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.insertColumns"), "IC must journal home.insertColumns");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_ie_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "IE", false);
|
||||
assert!(h.state().dialog.is_some(), "IE must open the insertCells dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_iw_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "IW", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.insertSheet"), "IW must journal home.insertSheet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_if_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "IF", false);
|
||||
assert!(h.state().dialog.is_some(), "IF must open the insertFunction dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_ind_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "IND", false);
|
||||
assert!(h.state().dialog.is_some(), "IND must open the defineName dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_is_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "IS", false);
|
||||
assert!(h.state().message.is_none() && h.state().toast.is_none(), "IS must route to its control without an error box");
|
||||
assert_eq!(h.state().keytips.prefix(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_ed_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "ED", false);
|
||||
assert!(h.state().dialog.is_some(), "ED must open the deleteCells dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_em_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "EM", false);
|
||||
assert!(h.state().dialog.is_some(), "EM must open the moveSheet dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_el_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "EL", false);
|
||||
assert!(h.state().message.is_some() || h.state().toast.is_some(), "EL must invoke home.deleteSheet and surface its missing-params error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_eaa_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "EAA", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearAll"), "EAA must journal edit.clearAll");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_eac_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "EAC", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearContents"), "EAC must journal edit.clearContents");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_eam_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "EAM", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.clearComments"), "EAM must journal edit.clearComments");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_ef_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "EF", false);
|
||||
assert!(h.state().dialog.is_some(), "EF must open the find dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_eg_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "EG", false);
|
||||
assert!(h.state().dialog.is_some(), "EG must open the goTo dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_ora_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "ORA", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.autofitRowHeight"), "ORA must journal home.autofitRowHeight");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_ore_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "ORE", false);
|
||||
assert!(h.state().dialog.is_some(), "ORE must open the rowHeight dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_orh_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "ORH", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.hideRows"), "ORH must journal home.hideRows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_oru_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "ORU", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.unhideRows"), "ORU must journal home.unhideRows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_ocw_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "OCW", false);
|
||||
assert!(h.state().dialog.is_some(), "OCW must open the columnWidth dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_och_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "OCH", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.hideColumns"), "OCH must journal home.hideColumns");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_ocu_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "OCU", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.unhideColumns"), "OCU must journal home.unhideColumns");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_ohr_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "OHR", false);
|
||||
assert!(h.state().dialog.is_some(), "OHR must open the renameSheet dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_ohh_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "OHH", false);
|
||||
assert!(h.state().message.is_some() || h.state().toast.is_some(), "OHH must invoke sheet.hide and surface its missing-params error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_ohu_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "OHU", false);
|
||||
assert!(h.state().message.is_some() || h.state().toast.is_some(), "OHU must invoke sheet.unhide and surface its missing-params error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_oe_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "OE", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.formatCells"), "OE must journal home.formatCells");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_od_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "OD", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.conditionalFormat"), "OD must journal home.conditionalFormat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_dff_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "DFF", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.filter"), "DFF must journal data.filter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_ds_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "DS", false);
|
||||
assert!(h.state().dialog.is_some(), "DS must open the sort dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_dgg_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "DGG", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.group"), "DGG must journal data.group");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_dgu_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "DGU", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.ungroup"), "DGU must journal data.ungroup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_dgs_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "DGS", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.showDetail"), "DGS must journal data.showDetail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_dgh_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "DGH", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.hideDetail"), "DGH must journal data.hideDetail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_dl_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "DL", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.validation"), "DL must journal data.validation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_dt_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "DT", false);
|
||||
assert!(h.state().message.is_some() || h.state().toast.is_some(), "DT must invoke data.dataTable and surface its missing-params error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_vn_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "VN", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.normal"), "VN must journal view.normal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_vp_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "VP", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.pageBreakPreview"), "VP must journal view.pageBreakPreview");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_vz_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "VZ", false);
|
||||
assert!(h.state().dialog.is_some(), "VZ must open the zoom dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_to_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "TO", false);
|
||||
assert!(h.state().backstage.is_some(), "TO must open the Options page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_tua_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "TUA", false);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.removeArrows"), "TUA must journal formulas.removeArrows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_tud_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "TUD", false);
|
||||
assert!(h.state().message.is_none() && h.state().toast.is_none(), "TUD must run formulas.traceDependents cleanly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_tut_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "TUT", false);
|
||||
assert!(h.state().message.is_none() && h.state().toast.is_none(), "TUT must run formulas.tracePrecedents cleanly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_fu_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "FU", false);
|
||||
assert!(h.state().dialog.is_some(), "FU must open the pageSetup dialog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keytip_q_() {
|
||||
let mut h = harness(blank());
|
||||
@@ -2766,7 +3068,7 @@ fn keytip_q_() {
|
||||
|
||||
#[test]
|
||||
fn generated_test_count_matches_the_table() {
|
||||
const EXPECTED: usize = 367;
|
||||
const EXPECTED: usize = 410;
|
||||
assert_eq!(gridcraft_ui_egui::keytips::keytip_table::SEQUENCES.len(), EXPECTED);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Excel 2003 menu access keys that open a ribbon control rather than run a command (#941).
|
||||
|
||||
use egui::os::OperatingSystem;
|
||||
use egui::{Event, Key, Modifiers};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
fn press(h: &mut egui_kittest::Harness<'static, SheetApp>, key: Key) {
|
||||
let ev = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE };
|
||||
h.input_mut().events.extend([ev(true), Event::Text(key.name().to_ascii_lowercase()), ev(false)]);
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_i_s_opens_the_symbol_gallery_and_leaves_the_cell_alone() {
|
||||
// Insert > Symbol picks a symbol; running insert.symbol with no symbol set the active cell
|
||||
// to its own text again, re-reading it (a Python cell lost its code, text could turn into a
|
||||
// number). From the Home tab the keys show the Insert tab's gallery instead.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "'007"})).unwrap();
|
||||
let mut h = crate::common::os_harness(s, OperatingSystem::Nix);
|
||||
h.run_steps(4);
|
||||
for key in [Key::F10, Key::I, Key::S] {
|
||||
press(&mut h, key);
|
||||
}
|
||||
h.run_steps(4);
|
||||
assert!(!h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol"), "nothing is inserted until a symbol is picked");
|
||||
assert_eq!(h.state().ui.ribbon_tab, "Insert");
|
||||
assert!(h.query_by_label("€").is_some(), "the Symbol gallery is open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_i_s_expands_a_collapsed_ribbon_on_the_insert_tab() {
|
||||
// With the Insert tab already chosen but the ribbon collapsed, the gallery's control isn't
|
||||
// drawn: the keys expand the ribbon first.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let mut h = crate::common::os_harness(s, OperatingSystem::Nix);
|
||||
h.state_mut().run("ui.ribbonTab", json!({"tab": "Insert"})).unwrap();
|
||||
h.state_mut().ui.ribbon_collapsed = true;
|
||||
h.run_steps(4);
|
||||
for key in [Key::F10, Key::I, Key::S] {
|
||||
press(&mut h, key);
|
||||
}
|
||||
h.run_steps(4);
|
||||
assert!(!h.state().ui.ribbon_collapsed);
|
||||
assert!(h.query_by_label("€").is_some(), "the Symbol gallery is open");
|
||||
assert!(!h.state().session.journal.iter().any(|(id, _)| id == "insert.symbol"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_waiting_key_tip_request_stays_with_its_window() {
|
||||
// A request waiting for the frame that draws its control belongs to the window whose keys
|
||||
// made it: another window drawn first must not take it.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let mut h = crate::common::os_harness(s, OperatingSystem::Nix);
|
||||
h.run_steps(2);
|
||||
let first = h.state().session.active_window().unwrap();
|
||||
h.state_mut().run("view.newWindow", json!({})).unwrap();
|
||||
let second = h.state().session.active_window().unwrap();
|
||||
h.state_mut().show_window(second);
|
||||
h.state_mut().ui.keytip_invoke_next = Some("insert.symbol");
|
||||
h.state_mut().show_window(first);
|
||||
assert_eq!(h.state().ui.keytip_invoke_next, None, "the first window doesn't see the second window's request");
|
||||
h.state_mut().show_window(second);
|
||||
assert_eq!(h.state().ui.keytip_invoke_next, Some("insert.symbol"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_window_drops_its_waiting_key_tip_request() {
|
||||
// A hidden window isn't drawn, so its waiting request would fire when it was shown again,
|
||||
// long after the keys: hiding the window drops it.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let mut h = crate::common::os_harness(s, OperatingSystem::Nix);
|
||||
h.run_steps(2);
|
||||
let first = h.state().session.active_window().unwrap();
|
||||
h.state_mut().run("view.newWindow", json!({})).unwrap();
|
||||
let second = h.state().session.active_window().unwrap();
|
||||
h.state_mut().show_window(second);
|
||||
h.state_mut().ui.keytip_invoke_next = Some("insert.symbol");
|
||||
h.state_mut().run("view.hideWindow", json!({"window": second})).unwrap();
|
||||
h.state_mut().show_window(first);
|
||||
h.state_mut().run("view.unhideWindow", json!({"window": second})).unwrap();
|
||||
h.state_mut().show_window(second);
|
||||
assert_eq!(h.state().ui.keytip_invoke_next, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_hidden_from_another_window_drops_its_parked_request() {
|
||||
// Hidden while another window is in front (its request already parked), the window still
|
||||
// loses the request, so showing it again doesn't open the control.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let mut h = crate::common::os_harness(s, OperatingSystem::Nix);
|
||||
h.run_steps(2);
|
||||
let first = h.state().session.active_window().unwrap();
|
||||
h.state_mut().run("view.newWindow", json!({})).unwrap();
|
||||
let second = h.state().session.active_window().unwrap();
|
||||
h.state_mut().show_window(second);
|
||||
h.state_mut().ui.keytip_invoke_next = Some("insert.symbol");
|
||||
h.state_mut().enter_window(first);
|
||||
h.state_mut().run("view.hideWindow", json!({"window": second})).unwrap();
|
||||
h.state_mut().show_window(first);
|
||||
h.state_mut().run("view.unhideWindow", json!({"window": second})).unwrap();
|
||||
h.state_mut().show_window(second);
|
||||
assert_eq!(h.state().ui.keytip_invoke_next, None);
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
mod common;
|
||||
|
||||
mod accounting_fill;
|
||||
mod active_row_highlight;
|
||||
mod advanced_filter;
|
||||
mod argument_names;
|
||||
mod autosave_persist;
|
||||
@@ -58,11 +59,13 @@ mod hyperlink_ctrl_click;
|
||||
mod hyperlink_dialog;
|
||||
mod in_cell_editor_size;
|
||||
mod in_cell_editor_wrap;
|
||||
mod ink_render;
|
||||
mod insert_copied_cells;
|
||||
mod interaction;
|
||||
mod interface_scale;
|
||||
mod keyboard_shortcuts;
|
||||
mod keytips_generated;
|
||||
mod legacy_keytips;
|
||||
mod macro_editor;
|
||||
mod macros_ui;
|
||||
mod menu_hover;
|
||||
|
||||
@@ -54,3 +54,50 @@ fn paste_link_pastes_references_at_once() {
|
||||
assert_eq!(c1["value"], json!(2.0));
|
||||
assert!(c1["formula"].as_str().is_some_and(|f| f.contains("A1")), "{c1}");
|
||||
}
|
||||
|
||||
fn press(h: &mut Harness, key: egui::Key) {
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers: egui::Modifiers::NONE });
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn value(h: &Harness, field: &str) -> serde_json::Value {
|
||||
h.state().dialog.as_ref().and_then(|d| d.values.get(field).cloned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_option_has_its_excel_access_key() {
|
||||
// #1482: only F, V, T, C and E worked.
|
||||
let mut h = harness();
|
||||
for (key, field, want) in [
|
||||
(egui::Key::N, "what", json!("validation")),
|
||||
(egui::Key::X, "what", json!("allExceptBorders")),
|
||||
(egui::Key::W, "what", json!("columnWidths")),
|
||||
(egui::Key::R, "what", json!("formulasAndNumberFormats")),
|
||||
(egui::Key::U, "what", json!("valuesAndNumberFormats")),
|
||||
(egui::Key::A, "what", json!("all")),
|
||||
(egui::Key::D, "operation", json!("add")),
|
||||
(egui::Key::S, "operation", json!("subtract")),
|
||||
(egui::Key::I, "operation", json!("divide")),
|
||||
(egui::Key::O, "operation", json!("none")),
|
||||
(egui::Key::M, "operation", json!("multiply")),
|
||||
(egui::Key::B, "skipBlanks", json!(true)),
|
||||
] {
|
||||
press(&mut h, key);
|
||||
assert_eq!(value(&h, field), want, "{key:?}");
|
||||
}
|
||||
press(&mut h, egui::Key::B);
|
||||
assert_eq!(value(&h, "skipBlanks"), json!(false), "B flips Skip blanks back");
|
||||
// V then Enter: values multiplied, as with the mouse.
|
||||
press(&mut h, egui::Key::V);
|
||||
press(&mut h, egui::Key::Enter);
|
||||
assert!(h.state().dialog.is_none());
|
||||
assert_eq!(cell(&mut h, "C1")["value"], json!(20.0));
|
||||
// L is the Paste Link button: it pastes at once.
|
||||
let mut h = harness();
|
||||
press(&mut h, egui::Key::L);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().dialog.is_none());
|
||||
assert!(cell(&mut h, "C1")["formula"].as_str().is_some_and(|f| f.contains("A1")));
|
||||
}
|
||||
|
||||
@@ -104,7 +104,10 @@ struct Clicks {
|
||||
#[test]
|
||||
/// The main button clicks once and the arrow twice separately; the tooltip keeps the platform shortcut, and the icon sits above the full-width label.
|
||||
fn big_split_button_separates_the_default_action_from_the_menu() {
|
||||
let mut h = crate::common::builder().with_size(vec2(300.0, 200.0)).build_ui_state(
|
||||
// The ribbon picks the shortcut by the OS egui reports, which kittest pins to Nix unless told:
|
||||
// pin macOS, so the check is the same on every host (the other platforms' text is in
|
||||
// `widgets::tests`).
|
||||
let mut h = crate::common::builder().with_size(vec2(300.0, 200.0)).with_os(egui::os::OperatingSystem::Mac).build_ui_state(
|
||||
|ui, c: &mut Clicks| {
|
||||
let (main, arrow) = widgets::big_split_button(ui, Icon::Sum, "Auto\nSum", "AutoSum");
|
||||
c.main += u32::from(main.clicked());
|
||||
@@ -125,8 +128,7 @@ fn big_split_button_separates_the_default_action_from_the_menu() {
|
||||
h.ctx.all_styles_mut(|s| s.interaction.tooltip_delay = 0.0);
|
||||
h.get_by_label("Auto Sum").hover();
|
||||
h.run_steps(6);
|
||||
let shortcut = if cfg!(target_os = "macos") { "AutoSum (\u{2318}\u{21e7}T)" } else { "AutoSum (Alt+=)" };
|
||||
assert!(h.query_by_value(shortcut).is_some(), "tooltip with the shortcut");
|
||||
assert!(h.query_by_value("AutoSum (\u{2318}\u{21e7}T)").is_some(), "tooltip with the macOS shortcut");
|
||||
// The icon part sits above the label part, across the full width.
|
||||
let main = h.get_by_label("Auto Sum").rect();
|
||||
let arrow = h.get_by_label("Auto Sum options").rect();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user