mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-28 20:56:54 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be01ee0cee |
-10
@@ -135,14 +135,6 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
|||||||
|
|
||||||
| Date | What landed |
|
| Date | What landed |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
|
|
||||||
| 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |
|
|
||||||
| 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline |
|
|
||||||
| 2026-10-24 | Fill handle: from a date, the right-drag menu and the Auto Fill Options button offer Fill Days, Fill Weekdays (Saturday and Sunday skipped), Fill Months and Fill Years |
|
|
||||||
| 2026-10-24 | PivotTables: Enable show details (`pivot.options` `enableShowDetails`); turned off, Show Details and double-clicking a value cell list no records, and XLSX files keep the setting (`enableDrill`) |
|
|
||||||
| 2026-10-24 | PivotTables: right-clicking a value, subtotal or grand total cell offers Show Details in the cell menu, which lists the cell's source records on a new sheet as double-clicking does |
|
|
||||||
| 2026-10-24 | Sheet tabs: dragging the handle between the tabs and the horizontal scrollbar gives the tabs more or less of the bar, as in Excel; each open workbook keeps its own share |
|
|
||||||
| 2026-10-24 | Charts: 100% Stacked Area (`insert.chart` `{type: "area", subtype: "stacked100"}`), drawn as each value's share of its category's total; XLSX `percentStacked` area charts open as 100% stacked instead of stacked |
|
|
||||||
| 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text |
|
| 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text |
|
||||||
| 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row |
|
| 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row |
|
||||||
| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) |
|
| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) |
|
||||||
@@ -189,7 +181,6 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
|||||||
| 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up |
|
| 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up |
|
||||||
| 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) |
|
| 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) |
|
||||||
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
|
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
|
||||||
| 2026-10-11 | UI building blocks: theme tokens with a WCAG contrast test over both themes (icons and reference colours follow dark mode); ribbon scroll arrows and a large split button (Formulas › AutoSum opens its menu); the formula bar grows from one to five lines, then scrolls; a code editor widget with Python and LaTeX colouring |
|
|
||||||
| 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found |
|
| 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found |
|
||||||
| 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) |
|
| 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) |
|
||||||
| 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) |
|
| 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) |
|
||||||
@@ -263,7 +254,6 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
|||||||
| 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) |
|
| 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) |
|
||||||
| 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) |
|
| 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) |
|
||||||
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
|
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
|
||||||
| 2026-10-11 | minor | UI building blocks; progress log row |
|
|
||||||
| 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row |
|
| 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row |
|
||||||
| 2026-10-11 | minor | Forecast Sheet (#322); progress log row |
|
| 2026-10-11 | minor | Forecast Sheet (#322); progress log row |
|
||||||
| 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row |
|
| 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row |
|
||||||
|
|||||||
@@ -248,11 +248,10 @@ impl Discovery {
|
|||||||
/// (`~$…`) are skipped.
|
/// (`~$…`) are skipped.
|
||||||
fn collect_inputs(inputs: &[String]) -> Discovery {
|
fn collect_inputs(inputs: &[String]) -> Discovery {
|
||||||
let mut out = Discovery::default();
|
let mut out = Discovery::default();
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
for i in inputs {
|
for i in inputs {
|
||||||
let p = PathBuf::from(i);
|
let p = PathBuf::from(i);
|
||||||
if p.is_dir() {
|
if p.is_dir() {
|
||||||
if !walk(&p, 0, &mut out, &mut seen) {
|
if !walk(&p, 0, &mut out) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else if !out.add_file(p) {
|
} else if !out.add_file(p) {
|
||||||
@@ -269,20 +268,7 @@ fn collect_inputs(inputs: &[String]) -> Discovery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns false only when the global file budget stops traversal.
|
/// Returns false only when the global file budget stops traversal.
|
||||||
fn walk(dir: &Path, depth: usize, out: &mut Discovery, seen: &mut std::collections::HashSet<u64>) -> bool {
|
fn walk(dir: &Path, depth: usize, out: &mut Discovery) -> bool {
|
||||||
// A directory is visited once by identity (dev+inode on unix): several links to the same
|
|
||||||
// directory are aliases, not exponential branches of the scan. Other platforms keep the
|
|
||||||
// depth cap only.
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::MetadataExt as _;
|
|
||||||
if let Ok(m) = std::fs::metadata(dir)
|
|
||||||
&& let Some(id) = m.dev().checked_mul(1 << 60).and_then(|d| d.checked_add(m.ino()))
|
|
||||||
&& !seen.insert(id)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if depth > MAX_DEPTH {
|
if depth > MAX_DEPTH {
|
||||||
out.fail(dir, format!("directory depth limit of {MAX_DEPTH} exceeded; this subtree was not checked"));
|
out.fail(dir, format!("directory depth limit of {MAX_DEPTH} exceeded; this subtree was not checked"));
|
||||||
return true;
|
return true;
|
||||||
@@ -315,10 +301,10 @@ fn walk(dir: &Path, depth: usize, out: &mut Discovery, seen: &mut std::collectio
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if metadata.is_dir() {
|
if metadata.is_dir() {
|
||||||
if !walk(&p, depth + 1, out, seen) {
|
if !walk(&p, depth + 1, out) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm"))
|
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm"))
|
||||||
&& !out.add_file(p)
|
&& !out.add_file(p)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -333,7 +319,7 @@ fn walk(dir: &Path, depth: usize, out: &mut Discovery, seen: &mut std::collectio
|
|||||||
pub fn check_bytes(path: &str, bytes: &[u8], opts: &Options) -> FileReport {
|
pub fn check_bytes(path: &str, bytes: &[u8], opts: &Options) -> FileReport {
|
||||||
let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| check_inner(path, bytes, opts)));
|
let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| check_inner(path, bytes, opts)));
|
||||||
guarded.unwrap_or_else(|p| {
|
guarded.unwrap_or_else(|p| {
|
||||||
let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||||
FileReport { path: path.to_string(), error: Some(format!("internal error while checking: {msg}")), ..Default::default() }
|
FileReport { path: path.to_string(), error: Some(format!("internal error while checking: {msg}")), ..Default::default() }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -995,12 +981,6 @@ fn relationship_kinds(bytes: &[u8]) -> Result<BTreeMap<String, String>, String>
|
|||||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?;
|
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?;
|
||||||
let names = zip_names(&mut zip);
|
let names = zip_names(&mut zip);
|
||||||
let by_key: HashMap<String, &String> = names.iter().map(|n| (part_key(n), n)).collect();
|
let by_key: HashMap<String, &String> = names.iter().map(|n| (part_key(n), n)).collect();
|
||||||
// Several relationships name the same part; each fresh check decompressed it again, so
|
|
||||||
// repeated references to one highly compressible part multiplied the work. The verdict is
|
|
||||||
// remembered per (part, named content) and the decompression the package can cause is
|
|
||||||
// budgeted as a whole (finding: repeated decompression).
|
|
||||||
let mut verdicts: HashMap<(String, &'static str), bool> = HashMap::new();
|
|
||||||
let mut budget: u64 = 512 * 1024 * 1024;
|
|
||||||
let mut kinds = BTreeMap::new();
|
let mut kinds = BTreeMap::new();
|
||||||
for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) {
|
for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) {
|
||||||
let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue };
|
let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue };
|
||||||
@@ -1014,7 +994,7 @@ fn relationship_kinds(bytes: &[u8]) -> Result<BTreeMap<String, String>, String>
|
|||||||
let external = attr(attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
|
let external = attr(attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
|
||||||
if !external
|
if !external
|
||||||
&& let Some(part) = by_key.get(&resolve_target(&src_dir, target).to_ascii_lowercase())
|
&& let Some(part) = by_key.get(&resolve_target(&src_dir, target).to_ascii_lowercase())
|
||||||
&& !holds_something(&mut zip, part, kind, &mut verdicts, &mut budget)
|
&& !holds_something(&mut zip, part, kind)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1033,39 +1013,13 @@ fn rels_source_dir(key: &str) -> Option<String> {
|
|||||||
|
|
||||||
/// Whether a part has content: an element under its root, or for comments (whose authors list
|
/// Whether a part has content: an element under its root, or for comments (whose authors list
|
||||||
/// comes first) a comment, for VML a shape. Read only as far as needed; an unreadable part counts.
|
/// comes first) a comment, for VML a shape. Read only as far as needed; an unreadable part counts.
|
||||||
/// `verdicts` remembers the answer per (part, named content); `budget` is the decompression this
|
fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str) -> bool {
|
||||||
/// package's remaining checks may cause, and once it is spent the part counts as having content.
|
|
||||||
fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str, verdicts: &mut HashMap<(String, &'static str), bool>, budget: &mut u64) -> bool {
|
|
||||||
let wanted: Option<&'static [u8]> = match kind.to_ascii_lowercase().as_str() {
|
|
||||||
"comments" => Some(b"comment"),
|
|
||||||
"vmldrawing" => Some(b"shape"),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
let marker = match wanted {
|
|
||||||
Some(b"comment") => "comment",
|
|
||||||
Some(_) => "shape",
|
|
||||||
None => "any",
|
|
||||||
};
|
|
||||||
if let Some(v) = verdicts.get(&(name.to_ascii_lowercase(), marker)) {
|
|
||||||
return *v;
|
|
||||||
}
|
|
||||||
let v = holds_something_uncached(zip, name, marker, budget);
|
|
||||||
verdicts.insert((name.to_ascii_lowercase(), marker), v);
|
|
||||||
v
|
|
||||||
}
|
|
||||||
|
|
||||||
fn holds_something_uncached(zip: &mut Zip<'_>, name: &str, marker: &'static str, budget: &mut u64) -> bool {
|
|
||||||
use quick_xml::events::Event;
|
use quick_xml::events::Event;
|
||||||
let Ok(f) = zip.by_name(name) else { return true };
|
let Ok(f) = zip.by_name(name) else { return true };
|
||||||
if *budget == 0 {
|
let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(MAX_XML_PART)));
|
||||||
return true;
|
let wanted: Option<&[u8]> = match kind.to_ascii_lowercase().as_str() {
|
||||||
}
|
"comments" => Some(b"comment"),
|
||||||
let take = f.size().min(MAX_XML_PART).min(*budget);
|
"vmldrawing" => Some(b"shape"),
|
||||||
*budget -= take;
|
|
||||||
let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(take)));
|
|
||||||
let wanted: Option<&'static [u8]> = match marker {
|
|
||||||
"comment" => Some(b"comment"),
|
|
||||||
"shape" => Some(b"shape"),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
@@ -1367,8 +1321,7 @@ mod tests {
|
|||||||
let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id()));
|
let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id()));
|
||||||
std::fs::write(&path, "not a directory").unwrap();
|
std::fs::write(&path, "not a directory").unwrap();
|
||||||
let mut discovery = Discovery::default();
|
let mut discovery = Discovery::default();
|
||||||
let mut seen = std::collections::HashSet::new();
|
assert!(walk(&path, 0, &mut discovery));
|
||||||
assert!(walk(&path, 0, &mut discovery, &mut seen));
|
|
||||||
assert!(discovery.files.is_empty());
|
assert!(discovery.files.is_empty());
|
||||||
assert_eq!(discovery.failures.len(), 1);
|
assert_eq!(discovery.failures.len(), 1);
|
||||||
assert_eq!(discovery.failures[0].path, path.to_string_lossy());
|
assert_eq!(discovery.failures[0].path, path.to_string_lossy());
|
||||||
|
|||||||
@@ -214,9 +214,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
|||||||
if let Some(w) = web_sys::window()
|
if let Some(w) = web_sys::window()
|
||||||
&& let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url)
|
&& let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url)
|
||||||
{
|
{
|
||||||
// With `noopener` the new browsing context can't hold a reference back to the
|
let _ = w.open_with_url_and_target(&url, "_blank");
|
||||||
// editor window and navigate it while the user reads the linked page.
|
|
||||||
let _ = w.open_with_url_and_target(&url, "_blank,noopener");
|
|
||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
inbox: Some(inbox),
|
inbox: Some(inbox),
|
||||||
@@ -352,7 +350,7 @@ fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
|
|||||||
js_sys::Reflect::get(&window, &JsValue::from_str("showSaveFilePicker")).ok().and_then(|v| v.dyn_into().ok());
|
js_sys::Reflect::get(&window, &JsValue::from_str("showSaveFilePicker")).ok().and_then(|v| v.dyn_into().ok());
|
||||||
|
|
||||||
if let Some(picker_fn) = show_picker_fn {
|
if let Some(picker_fn) = show_picker_fn {
|
||||||
let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("xlsx").to_ascii_lowercase();
|
let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("xlsx").to_ascii_lowercase();
|
||||||
let dot_ext = format!(".{ext}");
|
let dot_ext = format!(".{ext}");
|
||||||
let mime = mime_for(name);
|
let mime = mime_for(name);
|
||||||
|
|
||||||
|
|||||||
@@ -180,11 +180,11 @@ fn services() -> Services {
|
|||||||
})),
|
})),
|
||||||
pick_save: Some(Box::new(|suggested: &str| {
|
pick_save: Some(Box::new(|suggested: &str| {
|
||||||
let path = std::path::Path::new(suggested);
|
let path = std::path::Path::new(suggested);
|
||||||
let stem = path.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book1").to_string();
|
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
|
||||||
// The suggested name's type (an export asks for .pdf, .csv or .html) comes first.
|
// The suggested name's type (an export asks for .pdf, .csv or .html) comes first.
|
||||||
const TYPES: [(&str, &str); 4] =
|
const TYPES: [(&str, &str); 4] =
|
||||||
[("Excel Workbook (.xlsx)", "xlsx"), ("CSV UTF-8 (.csv)", "csv"), ("Web Page (.html)", "html"), ("PDF (.pdf)", "pdf")];
|
[("Excel Workbook (.xlsx)", "xlsx"), ("CSV UTF-8 (.csv)", "csv"), ("Web Page (.html)", "html"), ("PDF (.pdf)", "pdf")];
|
||||||
let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).filter(|e| TYPES.iter().any(|(_, x)| x == e));
|
let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).filter(|e| TYPES.iter().any(|(_, x)| x == e));
|
||||||
let ext = ext.unwrap_or_else(|| "xlsx".to_string());
|
let ext = ext.unwrap_or_else(|| "xlsx".to_string());
|
||||||
let mut dialog = rfd::FileDialog::new().set_file_name(format!("{stem}.{ext}"));
|
let mut dialog = rfd::FileDialog::new().set_file_name(format!("{stem}.{ext}"));
|
||||||
for (label, x) in TYPES.iter().filter(|(_, x)| *x == ext).chain(TYPES.iter().filter(|(_, x)| *x != ext)) {
|
for (label, x) in TYPES.iter().filter(|(_, x)| *x == ext).chain(TYPES.iter().filter(|(_, x)| *x != ext)) {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ pub fn discover() -> Discover {
|
|||||||
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
|
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
|
||||||
|
|
||||||
pub fn finish_pending_jobs() {
|
pub fn finish_pending_jobs() {
|
||||||
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
|
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner()));
|
||||||
for worker in workers {
|
for worker in workers {
|
||||||
if worker.join().is_err() {
|
if worker.join().is_err() {
|
||||||
log::error!("the print worker stopped unexpectedly during shutdown");
|
log::error!("the print worker stopped unexpectedly during shutdown");
|
||||||
@@ -45,7 +45,7 @@ pub fn submit() -> Submit {
|
|||||||
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
|
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
|
||||||
});
|
});
|
||||||
if let Ok(worker) = worker {
|
if let Ok(worker) = worker {
|
||||||
let mut jobs = JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner());
|
||||||
jobs.retain(|job| !job.is_finished());
|
jobs.retain(|job| !job.is_finished());
|
||||||
jobs.push(worker);
|
jobs.push(worker);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,3 @@ real_name = "Tyrel Haveman"
|
|||||||
|
|
||||||
[people.helenkwok]
|
[people.helenkwok]
|
||||||
real_name = "Helen Kwok"
|
real_name = "Helen Kwok"
|
||||||
|
|
||||||
[people.dotCooCoo]
|
|
||||||
real_name = "Robert Lee"
|
|
||||||
sync_github_name = true
|
|
||||||
|
|||||||
+9
-132
@@ -22,11 +22,6 @@ const FULL_SIZE_CELLS: u64 = 16_384;
|
|||||||
/// range, so that formulas over them stay fast.
|
/// range, so that formulas over them stay fast.
|
||||||
const FULL_SIZE_BOUNDED_CELLS: u64 = 1_048_576;
|
const FULL_SIZE_BOUNDED_CELLS: u64 = 1_048_576;
|
||||||
const MAX_DEPTH: usize = 400;
|
const MAX_DEPTH: usize = 400;
|
||||||
/// Lambda invocations one cell may make. A semantically bounded formula in Excel can invoke
|
|
||||||
/// its lambda at most once per cell of an array (capped here by `MAX_CELLS`), so this covers
|
|
||||||
/// every legitimate call; hostile work that grows past one dimension (a LAMBDA that recurses
|
|
||||||
/// or nest `MAP` into `MAP`) then stops at the same bound instead of squaring.
|
|
||||||
const LAMBDA_FUEL: u64 = MAX_CELLS;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
pub struct Area {
|
pub struct Area {
|
||||||
@@ -58,24 +53,6 @@ fn err(e: CellError) -> Ev {
|
|||||||
Ev::V(Value::Error(e))
|
Ev::V(Value::Error(e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LAMBDA calls that one formula may have in progress at once, counted the same way on every build.
|
|
||||||
/// The next call answers `#NUM!`. A countdown from 131, the deepest that `MAX_DEPTH` allows, has 132
|
|
||||||
/// calls in progress, so it still runs.
|
|
||||||
const MAX_LAMBDA_CALLS: usize = 140;
|
|
||||||
|
|
||||||
/// Native stack, in bytes, that one formula's evaluation may use below its first `eval`. Past it,
|
|
||||||
/// `eval` answers `#NUM!`. It guards the stack and leaves 128 KiB of a 1 MiB thread for the frames
|
|
||||||
/// above the formula. A cell that another cell's formula computes on demand shares the budget of the
|
|
||||||
/// outermost formula ([`Host::stack_top`]).
|
|
||||||
const STACK_BUDGET: usize = 896 * 1024;
|
|
||||||
|
|
||||||
/// The address of a local variable, which shows how far down the native stack this call is.
|
|
||||||
#[inline(always)]
|
|
||||||
pub(crate) fn stack_position() -> usize {
|
|
||||||
let marker = 0u8;
|
|
||||||
std::hint::black_box(&marker) as *const u8 as usize
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What the evaluator needs from its surroundings.
|
/// What the evaluator needs from its surroundings.
|
||||||
pub trait Host {
|
pub trait Host {
|
||||||
fn workbook(&self) -> &Workbook;
|
fn workbook(&self) -> &Workbook;
|
||||||
@@ -103,13 +80,6 @@ pub trait Host {
|
|||||||
fn visit_range(&mut self, _sheet: usize, _range: RangeRef, _f: &mut dyn FnMut(&Value) -> bool) -> bool {
|
fn visit_range(&mut self, _sheet: usize, _range: RangeRef, _f: &mut dyn FnMut(&Value) -> bool) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
/// While a formula is calculated on demand inside another one: `stack_position()` where the
|
|
||||||
/// outermost one started, which the stack budget is counted from.
|
|
||||||
fn stack_top(&self) -> Option<usize> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
/// Called when a formula's evaluation runs past the stack budget.
|
|
||||||
fn stack_exhausted(&mut self) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Evaluator<'h> {
|
pub struct Evaluator<'h> {
|
||||||
@@ -118,15 +88,6 @@ pub struct Evaluator<'h> {
|
|||||||
pub at: CellRef,
|
pub at: CellRef,
|
||||||
env: Vec<(String, Ev)>,
|
env: Vec<(String, Ev)>,
|
||||||
depth: usize,
|
depth: usize,
|
||||||
/// LAMBDA calls in progress.
|
|
||||||
calls: usize,
|
|
||||||
/// `stack_position()` at the outermost `eval`.
|
|
||||||
stack_top: usize,
|
|
||||||
/// Lambda invocations this cell still has left. Every call spends one charge, so a
|
|
||||||
/// compact hostile formula (a LAMBDA that calls itself, nested `MAP`s over big arrays)
|
|
||||||
/// runs out of work after the same number of calls that a legitimately wide one could
|
|
||||||
/// make, instead of an amount of work that squares with array nesting (#2382).
|
|
||||||
fuel: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FnCtx<'a, 'h> {
|
struct FnCtx<'a, 'h> {
|
||||||
@@ -166,20 +127,8 @@ impl Ctx for FnCtx<'_, '_> {
|
|||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut ev = Evaluator {
|
let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth };
|
||||||
host: &mut *self.ev.host,
|
Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true)))
|
||||||
sheet: area.sheet,
|
|
||||||
at,
|
|
||||||
env,
|
|
||||||
depth: self.ev.depth,
|
|
||||||
calls: self.ev.calls,
|
|
||||||
stack_top: self.ev.stack_top,
|
|
||||||
fuel: self.ev.fuel,
|
|
||||||
};
|
|
||||||
let v = matches!(ev.value(&shifted).scalar().to_bool(), Ok(true));
|
|
||||||
// The child's spend counts against this cell's budget.
|
|
||||||
self.ev.fuel = ev.fuel;
|
|
||||||
Some(v)
|
|
||||||
}
|
}
|
||||||
fn has_lambda(&self) -> bool {
|
fn has_lambda(&self) -> bool {
|
||||||
self.lambda.is_some()
|
self.lambda.is_some()
|
||||||
@@ -198,7 +147,7 @@ impl Ctx for FnCtx<'_, '_> {
|
|||||||
|
|
||||||
impl<'h> Evaluator<'h> {
|
impl<'h> Evaluator<'h> {
|
||||||
pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self {
|
pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self {
|
||||||
Evaluator { host, sheet, at, env: Vec::new(), depth: 0, calls: 0, stack_top: 0, fuel: LAMBDA_FUEL }
|
Evaluator { host, sheet, at, env: Vec::new(), depth: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluates to a value (references are dereferenced; multi-cell → array).
|
/// Evaluates to a value (references are dereferenced; multi-cell → array).
|
||||||
@@ -284,18 +233,10 @@ impl<'h> Evaluator<'h> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn eval(&mut self, e: &Expr) -> Ev {
|
pub fn eval(&mut self, e: &Expr) -> Ev {
|
||||||
let here = stack_position();
|
|
||||||
if self.depth == 0 {
|
|
||||||
self.stack_top = self.host.stack_top().unwrap_or(here);
|
|
||||||
}
|
|
||||||
if self.stack_top.abs_diff(here) > STACK_BUDGET {
|
|
||||||
self.host.stack_exhausted();
|
|
||||||
return err(CellError::Num);
|
|
||||||
}
|
|
||||||
self.depth += 1;
|
self.depth += 1;
|
||||||
if self.depth > MAX_DEPTH {
|
if self.depth > MAX_DEPTH {
|
||||||
self.depth -= 1;
|
self.depth -= 1;
|
||||||
return err(CellError::Num);
|
return err(CellError::Calc);
|
||||||
}
|
}
|
||||||
let r = self.eval_inner(e);
|
let r = self.eval_inner(e);
|
||||||
self.depth -= 1;
|
self.depth -= 1;
|
||||||
@@ -579,7 +520,7 @@ impl<'h> Evaluator<'h> {
|
|||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// A long chain such as A1+A2+…+A500 nests to the left: fold it in a loop instead
|
// A long chain such as A1+A2+…+A500 nests to the left: fold it in a loop instead
|
||||||
// of one level of recursion per operator (past the depth limit it was an error).
|
// of one level of recursion per operator (past the depth limit it was #CALC!).
|
||||||
let mut rest = vec![(op, b)];
|
let mut rest = vec![(op, b)];
|
||||||
let mut left = a;
|
let mut left = a;
|
||||||
while let Expr::Binary(o, l, r) = left
|
while let Expr::Binary(o, l, r) = left
|
||||||
@@ -888,11 +829,10 @@ impl<'h> Evaluator<'h> {
|
|||||||
fn builtin(&mut self, name: &str, args: &[Expr]) -> Ev {
|
fn builtin(&mut self, name: &str, args: &[Expr]) -> Ev {
|
||||||
let Some(spec) = gridcraft_functions::lookup(name) else {
|
let Some(spec) = gridcraft_functions::lookup(name) else {
|
||||||
// A defined name holding a LAMBDA, or a LET-bound lambda.
|
// A defined name holding a LAMBDA, or a LET-bound lambda.
|
||||||
return match self.name(name) {
|
if let Ev::L(l) = self.name(name) {
|
||||||
Ev::L(l) => self.apply_lambda(&l, args),
|
return self.apply_lambda(&l, args);
|
||||||
Ev::V(Value::Error(x)) => err(x),
|
}
|
||||||
_ => err(CellError::Name),
|
return err(CellError::Name);
|
||||||
};
|
|
||||||
};
|
};
|
||||||
if args.len() < spec.min_args || args.len() > spec.max_args {
|
if args.len() < spec.min_args || args.len() > spec.max_args {
|
||||||
return err(CellError::Value);
|
return err(CellError::Value);
|
||||||
@@ -1043,22 +983,10 @@ impl<'h> Evaluator<'h> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev {
|
fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev {
|
||||||
if self.calls >= MAX_LAMBDA_CALLS {
|
|
||||||
return err(CellError::Num);
|
|
||||||
}
|
|
||||||
// #2382: one charge per invocation, so a cell's total work stays bounded however the
|
|
||||||
// lambdas nest. Foregrounding the charge here means every path into a body (direct
|
|
||||||
// `(2,3)` application, `MAP`/`REDUCE`/`BYROW` helpers, D-function criteria) meters.
|
|
||||||
self.fuel = match self.fuel.checked_sub(1) {
|
|
||||||
Some(f) => f,
|
|
||||||
None => return err(CellError::Calc),
|
|
||||||
};
|
|
||||||
self.calls += 1;
|
|
||||||
let saved = std::mem::replace(&mut self.env, l.env.clone());
|
let saved = std::mem::replace(&mut self.env, l.env.clone());
|
||||||
self.env.extend(bound);
|
self.env.extend(bound);
|
||||||
let r = self.eval(&l.body);
|
let r = self.eval(&l.body);
|
||||||
self.env = saved;
|
self.env = saved;
|
||||||
self.calls -= 1;
|
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2056,54 +1984,3 @@ pub fn is_known_function(n: &str) -> bool {
|
|||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn unused(_: RefKind) {}
|
fn unused(_: RefKind) {}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod fuel_tests {
|
|
||||||
use super::*;
|
|
||||||
use gridcraft_model::Workbook;
|
|
||||||
|
|
||||||
struct Empty {
|
|
||||||
wb: Workbook,
|
|
||||||
}
|
|
||||||
impl Host for Empty {
|
|
||||||
fn workbook(&self) -> &Workbook {
|
|
||||||
&self.wb
|
|
||||||
}
|
|
||||||
fn cell_value(&mut self, _sheet: usize, _c: CellRef) -> Value {
|
|
||||||
Value::Empty
|
|
||||||
}
|
|
||||||
fn now_serial(&self) -> f64 {
|
|
||||||
0.0
|
|
||||||
}
|
|
||||||
fn random(&mut self) -> f64 {
|
|
||||||
0.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn eval_with(fuel: u64, src: &str) -> Value {
|
|
||||||
let mut host = Empty { wb: Workbook::default() };
|
|
||||||
let mut ev = Evaluator::new(&mut host, 0, CellRef::new(0, 0));
|
|
||||||
ev.fuel = fuel;
|
|
||||||
ev.value(&gridcraft_formula::parser::parse(src).unwrap_or_else(|e| panic!("{e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lambda_calls_spend_fuel_and_run_out_quietly() {
|
|
||||||
// Three applications, one charge each: 3 inside the budget. (A nested MAP wouldn't do
|
|
||||||
// here: an inner MAP result is an array, and MAP of array elements is #CALC! on shape,
|
|
||||||
// which happens well before the budget does.)
|
|
||||||
let r = eval_with(3, "=LAMBDA(x,LAMBDA(y,LAMBDA(z,z+1)(3))(2))(1)");
|
|
||||||
assert_eq!(r, Value::number(4.0), "{r:?}");
|
|
||||||
// One charge short: the nest bottoms out in #CALC! instead of running unbounded.
|
|
||||||
let r = eval_with(2, "=LAMBDA(x,LAMBDA(y,LAMBDA(z,z+1)(3))(2))(1)");
|
|
||||||
assert!(matches!(r, Value::Error(CellError::Calc)), "{r:?}");
|
|
||||||
// One charge short of a single application: #CALC! with a zero budget.
|
|
||||||
let r = eval_with(0, "=LAMBDA(x,x+1)(2)");
|
|
||||||
assert!(matches!(r, Value::Error(CellError::Calc)), "{r:?}");
|
|
||||||
let r = eval_with(1, "=LAMBDA(x,x+1)(2)");
|
|
||||||
assert_eq!(r, Value::number(3.0));
|
|
||||||
// A flat helper map keeps working (3 of 3 invokes inside the budget).
|
|
||||||
let r = eval_with(3, "=MAP({1,2,3},LAMBDA(x,x*2))");
|
|
||||||
assert!(matches!(r, Value::Array(_)) && !matches!(&r, Value::Array(a) if a.data.iter().any(|v| matches!(v, Value::Error(_)))), "{r:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-140
@@ -44,7 +44,7 @@ use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
|
|||||||
use gridcraft_formula::Expr;
|
use gridcraft_formula::Expr;
|
||||||
use gridcraft_model::{CalcMode, Workbook};
|
use gridcraft_model::{CalcMode, Workbook};
|
||||||
|
|
||||||
use crate::eval::{Area, Evaluator, Host, precedents, stack_position};
|
use crate::eval::{Area, Evaluator, Host, precedents};
|
||||||
|
|
||||||
pub type Key = (usize, CellRef);
|
pub type Key = (usize, CellRef);
|
||||||
|
|
||||||
@@ -432,10 +432,6 @@ struct PassHost<'a> {
|
|||||||
tainted: HashSet<Key>,
|
tainted: HashSet<Key>,
|
||||||
/// Whether the formula being evaluated has read a previous value.
|
/// Whether the formula being evaluated has read a previous value.
|
||||||
taint: bool,
|
taint: bool,
|
||||||
/// `stack_position()` where the outermost formula being calculated started.
|
|
||||||
stack_top: usize,
|
|
||||||
/// Whether an evaluation ran past the stack budget since the innermost formula started.
|
|
||||||
out_of_stack: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> PassHost<'a> {
|
impl<'a> PassHost<'a> {
|
||||||
@@ -466,8 +462,6 @@ impl<'a> PassHost<'a> {
|
|||||||
members: HashSet::default(),
|
members: HashSet::default(),
|
||||||
tainted: HashSet::default(),
|
tainted: HashSet::default(),
|
||||||
taint: false,
|
taint: false,
|
||||||
stack_top: 0,
|
|
||||||
out_of_stack: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -586,9 +580,6 @@ impl PassHost<'_> {
|
|||||||
self.suspend = Some(k);
|
self.suspend = Some(k);
|
||||||
return Value::Error(CellError::Calc);
|
return Value::Error(CellError::Calc);
|
||||||
}
|
}
|
||||||
if self.depth == 0 {
|
|
||||||
self.stack_top = stack_position();
|
|
||||||
}
|
|
||||||
self.in_progress.insert(k);
|
self.in_progress.insert(k);
|
||||||
self.depth += 1;
|
self.depth += 1;
|
||||||
let outer_taint = std::mem::replace(&mut self.taint, false);
|
let outer_taint = std::mem::replace(&mut self.taint, false);
|
||||||
@@ -596,24 +587,10 @@ impl PassHost<'_> {
|
|||||||
self.stack.push(k);
|
self.stack.push(k);
|
||||||
}
|
}
|
||||||
let expr = std::sync::Arc::clone(expr);
|
let expr = std::sync::Arc::clone(expr);
|
||||||
let outer_out_of_stack = std::mem::replace(&mut self.out_of_stack, false);
|
|
||||||
let v = {
|
let v = {
|
||||||
let mut ev = Evaluator::new(self, k.0, k.1);
|
let mut ev = Evaluator::new(self, k.0, k.1);
|
||||||
ev.value(&expr)
|
ev.value(&expr)
|
||||||
};
|
};
|
||||||
let out_of_stack = std::mem::replace(&mut self.out_of_stack, outer_out_of_stack);
|
|
||||||
if out_of_stack && self.depth > 1 && self.suspend.is_none() {
|
|
||||||
// It ran out of the stack left below the formulas that read it: it is calculated
|
|
||||||
// from the top instead, as a formula needed at `MAX_NESTED` levels deep is.
|
|
||||||
self.suspend = Some(k);
|
|
||||||
self.depth -= 1;
|
|
||||||
self.in_progress.remove(&k);
|
|
||||||
if self.iterate {
|
|
||||||
self.stack.pop();
|
|
||||||
}
|
|
||||||
self.taint = outer_taint;
|
|
||||||
return Value::Error(CellError::Calc);
|
|
||||||
}
|
|
||||||
if self.suspend.is_some() {
|
if self.suspend.is_some() {
|
||||||
// Abandoned: `calculate` evaluates this formula again once the one it needs is done.
|
// Abandoned: `calculate` evaluates this formula again once the one it needs is done.
|
||||||
self.depth -= 1;
|
self.depth -= 1;
|
||||||
@@ -768,12 +745,6 @@ impl Host for PassHost<'_> {
|
|||||||
fn workbook(&self) -> &Workbook {
|
fn workbook(&self) -> &Workbook {
|
||||||
self.wb
|
self.wb
|
||||||
}
|
}
|
||||||
fn stack_top(&self) -> Option<usize> {
|
|
||||||
(self.depth > 1).then_some(self.stack_top)
|
|
||||||
}
|
|
||||||
fn stack_exhausted(&mut self) {
|
|
||||||
self.out_of_stack = true;
|
|
||||||
}
|
|
||||||
fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option<RangeRef> {
|
fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option<RangeRef> {
|
||||||
let k = (sheet, anchor);
|
let k = (sheet, anchor);
|
||||||
if !(self.pending.contains(&k) || self.results.contains_key(&k)) {
|
if !(self.pending.contains(&k) || self.results.contains_key(&k)) {
|
||||||
@@ -942,7 +913,6 @@ impl Calc {
|
|||||||
/// Rebuilds the graph from scratch.
|
/// Rebuilds the graph from scratch.
|
||||||
pub fn rebuild(&mut self, wb: &Workbook) {
|
pub fn rebuild(&mut self, wb: &Workbook) {
|
||||||
self.graph = Graph::default();
|
self.graph = Graph::default();
|
||||||
let mut circ = Vec::new();
|
|
||||||
for (si, sheet) in wb.sheets.iter().enumerate() {
|
for (si, sheet) in wb.sheets.iter().enumerate() {
|
||||||
for (c, cell) in sheet.cells.iter() {
|
for (c, cell) in sheet.cells.iter() {
|
||||||
if let Some(f) = &cell.formula
|
if let Some(f) = &cell.formula
|
||||||
@@ -950,106 +920,10 @@ impl Calc {
|
|||||||
{
|
{
|
||||||
let (areas, dynamic) = precedents(wb, si, &e);
|
let (areas, dynamic) = precedents(wb, si, &e);
|
||||||
self.graph.insert((si, c), Node { areas, dynamic });
|
self.graph.insert((si, c), Node { areas, dynamic });
|
||||||
if cell.value == Value::Error(CellError::Circ) {
|
|
||||||
circ.push((si, c));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.graph.settle();
|
self.graph.settle();
|
||||||
self.circular = if wb.calc.iterative { Vec::new() } else { self.loops_among(&circ) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The circular references of a rebuilt workbook (after undo, redo or a rollback, which put
|
|
||||||
/// back cells without calculating them), from the formulas whose cached value is `#CIRC!`:
|
|
||||||
/// the listed cells still on a loop, and one cell of each loop none of them is on.
|
|
||||||
fn loops_among(&self, circ: &[Key]) -> Vec<Key> {
|
|
||||||
if circ.is_empty() {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
let index: HashMap<Key, usize> = circ.iter().enumerate().map(|(i, k)| (*k, i)).collect();
|
|
||||||
// Edges from a cell to the `#CIRC!` formulas that read it; loops are the same either way.
|
|
||||||
let mut deps = Vec::new();
|
|
||||||
let edges: Vec<Vec<usize>> = circ
|
|
||||||
.iter()
|
|
||||||
.map(|k| {
|
|
||||||
deps.clear();
|
|
||||||
self.graph.dependents(k.0, k.1, &mut deps);
|
|
||||||
let mut e: Vec<usize> = deps.iter().filter_map(|d| index.get(d).copied()).collect();
|
|
||||||
e.sort_unstable();
|
|
||||||
e.dedup();
|
|
||||||
e
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
// Tarjan's strongly connected components, iterative.
|
|
||||||
let n = circ.len();
|
|
||||||
let (mut low, mut num) = (vec![0usize; n], vec![usize::MAX; n]);
|
|
||||||
let (mut on_stack, mut stack, mut comp) = (vec![false; n], Vec::new(), vec![usize::MAX; n]);
|
|
||||||
let (mut next, mut comps) = (0usize, 0usize);
|
|
||||||
for root in 0..n {
|
|
||||||
if num[root] != usize::MAX {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut call: Vec<(usize, usize)> = vec![(root, 0)];
|
|
||||||
num[root] = next;
|
|
||||||
low[root] = next;
|
|
||||||
next += 1;
|
|
||||||
stack.push(root);
|
|
||||||
on_stack[root] = true;
|
|
||||||
while let Some(top) = call.last_mut() {
|
|
||||||
let v = top.0;
|
|
||||||
if let Some(&w) = edges.get(v).and_then(|e| e.get(top.1)) {
|
|
||||||
top.1 += 1;
|
|
||||||
if num[w] == usize::MAX {
|
|
||||||
num[w] = next;
|
|
||||||
low[w] = next;
|
|
||||||
next += 1;
|
|
||||||
stack.push(w);
|
|
||||||
on_stack[w] = true;
|
|
||||||
call.push((w, 0));
|
|
||||||
} else if on_stack[w] {
|
|
||||||
low[v] = low[v].min(num[w]);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
call.pop();
|
|
||||||
if let Some(&(u, _)) = call.last() {
|
|
||||||
low[u] = low[u].min(low[v]);
|
|
||||||
}
|
|
||||||
if low[v] == num[v] {
|
|
||||||
while let Some(w) = stack.pop() {
|
|
||||||
on_stack[w] = false;
|
|
||||||
comp[w] = comps;
|
|
||||||
if w == v {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
comps += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut size = vec![0usize; comps];
|
|
||||||
for &c in &comp {
|
|
||||||
if let Some(s) = size.get_mut(c) {
|
|
||||||
*s += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let on_loop = |i: usize| comp.get(i).and_then(|c| size.get(*c)).is_some_and(|s| *s > 1) || edges.get(i).is_some_and(|e| e.contains(&i));
|
|
||||||
// A formula with dynamic references (INDIRECT, OFFSET) may close a loop the graph can't see.
|
|
||||||
let mut out: Vec<Key> =
|
|
||||||
self.circular.iter().copied().filter(|k| index.get(k).is_some_and(|&i| on_loop(i) || self.graph.dynamic.contains(k))).collect();
|
|
||||||
let mut covered: HashSet<usize> = out.iter().filter_map(|k| index.get(k)).filter_map(|&i| comp.get(i).copied()).collect();
|
|
||||||
let mut order: Vec<usize> = (0..n).collect();
|
|
||||||
order.sort_by_key(|&i| circ.get(i).map(|(s, c)| (*s, c.row, c.col)));
|
|
||||||
for i in order {
|
|
||||||
if let (Some(&c), Some(&k)) = (comp.get(i), circ.get(i))
|
|
||||||
&& on_loop(i)
|
|
||||||
&& covered.insert(c)
|
|
||||||
{
|
|
||||||
out.push(k);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recalculates every formula (F9 / Ctrl+Alt+F9, and after loading).
|
/// Recalculates every formula (F9 / Ctrl+Alt+F9, and after loading).
|
||||||
@@ -1229,12 +1103,7 @@ impl Calc {
|
|||||||
}
|
}
|
||||||
let tw = prof_now();
|
let tw = prof_now();
|
||||||
self.last_recalc_cells += results.len();
|
self.last_recalc_cells += results.len();
|
||||||
// The loops this round finds replace the ones listed for the formulas it evaluated.
|
self.circular = cycles;
|
||||||
if !self.circular.is_empty() {
|
|
||||||
let evaluated: HashSet<Key> = dirty.iter().copied().collect();
|
|
||||||
self.circular.retain(|k| !evaluated.contains(k));
|
|
||||||
}
|
|
||||||
self.circular.extend(cycles);
|
|
||||||
// Write back.
|
// Write back.
|
||||||
let mut spill_changes: Vec<Key> = Vec::new();
|
let mut spill_changes: Vec<Key> = Vec::new();
|
||||||
let mut freed: Vec<(usize, RangeRef)> = Vec::new();
|
let mut freed: Vec<(usize, RangeRef)> = Vec::new();
|
||||||
@@ -1347,13 +1216,6 @@ impl Calc {
|
|||||||
}
|
}
|
||||||
dirty = self.dirty_closure(wb, &spill_changes);
|
dirty = self.dirty_closure(wb, &spill_changes);
|
||||||
}
|
}
|
||||||
// Loops among formulas the run did not evaluate stay listed while they are still formulas.
|
|
||||||
// Iterative calculation reports none.
|
|
||||||
if wb.calc.iterative {
|
|
||||||
self.circular.clear();
|
|
||||||
} else {
|
|
||||||
self.circular.retain(|k| self.graph.nodes.contains_key(k));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -384,32 +384,6 @@ fn circular_references_without_iterative_calculation() {
|
|||||||
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
|
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The list of circular references keeps the loops that a recalculation of other cells does not
|
|
||||||
/// reach, and drops a loop once a constant is typed over one of its cells.
|
|
||||||
#[test]
|
|
||||||
fn circular_references_stay_listed_until_the_loop_is_removed() {
|
|
||||||
let mut t = T::new();
|
|
||||||
t.set("A1", "=A1+1");
|
|
||||||
t.set("B1", "=B2");
|
|
||||||
t.set("B2", "=B1");
|
|
||||||
assert!(t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular);
|
|
||||||
t.set("D4", "7");
|
|
||||||
t.set("D5", "=D4*2");
|
|
||||||
assert!(t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular);
|
|
||||||
assert!(t.calc.circular.iter().any(|k| *k == (0, c("B1")) || *k == (0, c("B2"))), "{:?}", t.calc.circular);
|
|
||||||
t.set("A1", "5");
|
|
||||||
assert!(!t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular);
|
|
||||||
assert!(t.calc.circular.iter().any(|k| *k == (0, c("B1")) || *k == (0, c("B2"))), "{:?}", t.calc.circular);
|
|
||||||
t.set("B2", "3");
|
|
||||||
assert!(t.calc.circular.is_empty(), "{:?}", t.calc.circular);
|
|
||||||
// In manual calculation only the edited formulas are evaluated; a constant typed over the listed cell removes it.
|
|
||||||
t.wb.calc.mode = gridcraft_model::CalcMode::Manual;
|
|
||||||
t.set("C1", "=C1+1");
|
|
||||||
assert_eq!(t.calc.circular, vec![(0, c("C1"))]);
|
|
||||||
t.set("C1", "1");
|
|
||||||
assert!(t.calc.circular.is_empty(), "{:?}", t.calc.circular);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn date_text_coerces_in_the_workbook_date_system() {
|
fn date_text_coerces_in_the_workbook_date_system() {
|
||||||
for (sys, serial) in [(gridcraft_core::DateSystem::D1900, 1462.0), (gridcraft_core::DateSystem::D1904, 0.0)] {
|
for (sys, serial) in [(gridcraft_core::DateSystem::D1900, 1462.0), (gridcraft_core::DateSystem::D1904, 0.0)] {
|
||||||
@@ -596,24 +570,6 @@ fn lambda_helpers_check_shapes_and_parameters() {
|
|||||||
assert_eq!(t.num("B7"), 2.0);
|
assert_eq!(t.num("B7"), 2.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lambda_calls_spend_one_work_budget_per_cell() {
|
|
||||||
// #2382: lambda nesting multiplies invocations, and the budget meters every one of them.
|
|
||||||
// Exhaustion itself is tested on a mock host in `eval::fuel_tests` (burning the real 16M
|
|
||||||
// budget in a full recalc needs tens of millions of calls, far too slow for a unit test);
|
|
||||||
// here the same nested shape runs inside it: 6 × 9 ≈ 59k scalar REDUCE invocations.
|
|
||||||
let mut t = T::new();
|
|
||||||
t.set(
|
|
||||||
"A1",
|
|
||||||
"=LET(s,SEQUENCE(9),REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,a+v)))))))))))",
|
|
||||||
);
|
|
||||||
// The bodies are the same inner reduce, so every level sums to 45 while the invocation
|
|
||||||
// count multiplies by 9 per level (9, 81, 729, 6561, 59049 calls).
|
|
||||||
assert_eq!(t.num("A1"), 45.0);
|
|
||||||
t.set("A2", "=REDUCE(0,SEQUENCE(4000),LAMBDA(a,v,a+v))");
|
|
||||||
assert_eq!(t.num("A2"), 4000.0 * 4001.0 / 2.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn accounting_negative_text_coerces() {
|
fn accounting_negative_text_coerces() {
|
||||||
let mut t = T::new();
|
let mut t = T::new();
|
||||||
@@ -1051,100 +1007,6 @@ fn lambda_argument_count() {
|
|||||||
assert_eq!(t.get("A9"), Value::Bool(true));
|
assert_eq!(t.get("A9"), Value::Bool(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_lambda_that_calls_itself_without_end_is_num_on_a_small_stack() {
|
|
||||||
// 1 MiB is the main thread's stack on Windows, where commands, recalculation and file open run.
|
|
||||||
let handle = std::thread::Builder::new()
|
|
||||||
.stack_size(1 << 20)
|
|
||||||
.spawn(|| {
|
|
||||||
let mut t = T::new();
|
|
||||||
t.wb.names.push(gridcraft_model::DefinedName {
|
|
||||||
name: "LOOP".into(),
|
|
||||||
scope: None,
|
|
||||||
formula: "LAMBDA(n,LOOP(n))".into(),
|
|
||||||
comment: String::new(),
|
|
||||||
hidden: false,
|
|
||||||
});
|
|
||||||
t.calc.rebuild(&t.wb);
|
|
||||||
let endless = "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))";
|
|
||||||
t.set("A1", endless);
|
|
||||||
t.set("A2", "=LOOP(1)");
|
|
||||||
t.set("A3", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+SUM(MAP({1},LAMBDA(v,me(me,n-1)))))),F(F,100000))");
|
|
||||||
t.set("A4", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+me(me,n-1))),F(F,131))");
|
|
||||||
let evaluated = crate::evaluate(&t.wb, 0, c("B1"), endless);
|
|
||||||
[t.get("A1"), t.get("A2"), t.get("A3"), evaluated, t.get("A4")]
|
|
||||||
})
|
|
||||||
.expect("spawn");
|
|
||||||
let [endless, endless_name, through_map, evaluated, countdown] = handle.join().expect("recursion must not overflow the stack");
|
|
||||||
assert_eq!(endless, Value::Error(CellError::Num));
|
|
||||||
assert_eq!(endless_name, Value::Error(CellError::Num));
|
|
||||||
assert_eq!(through_map, Value::Error(CellError::Num));
|
|
||||||
assert_eq!(evaluated, Value::Error(CellError::Num));
|
|
||||||
assert_eq!(countdown, Value::Number(131.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_recursive_lambda_stops_at_the_same_call_on_any_stack_size() {
|
|
||||||
let run = |stack: usize| {
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.stack_size(stack)
|
|
||||||
.spawn(|| {
|
|
||||||
let mut wb = Workbook::new();
|
|
||||||
wb.names.push(gridcraft_model::DefinedName {
|
|
||||||
name: "CNT".into(),
|
|
||||||
scope: None,
|
|
||||||
formula: "LAMBDA(n,IF(n=0,0,1+CNT(n-1)))".into(),
|
|
||||||
comment: String::new(),
|
|
||||||
hidden: false,
|
|
||||||
});
|
|
||||||
// F(F,n) has n + 1 calls in progress at its deepest; W adds one more call per step.
|
|
||||||
let tail = |n: u32| format!("=LET(F,LAMBDA(me,n,IF(n=0,0,me(me,n-1))),F(F,{n}))");
|
|
||||||
let wrapped = |n: u32| format!("=LET(W,LAMBDA(f,k,f(f,k)),F,LAMBDA(me,n,IF(n<=0,0,1+W(me,n-1))),F(F,{n}))");
|
|
||||||
[tail(139), tail(140), wrapped(69), wrapped(70), "=CNT(131)".to_string()].map(|f| crate::evaluate(&wb, 0, c("B1"), &f))
|
|
||||||
})
|
|
||||||
.expect("spawn")
|
|
||||||
.join()
|
|
||||||
.expect("recursion must not overflow the stack")
|
|
||||||
};
|
|
||||||
let small = run(1 << 20);
|
|
||||||
assert_eq!(small, [Value::Number(0.0), Value::Error(CellError::Num), Value::Number(69.0), Value::Error(CellError::Num), Value::Number(131.0)]);
|
|
||||||
assert_eq!(run(64 << 20), small);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_recursive_lambda_that_reads_another_cells_recursive_lambda_shares_the_stack() {
|
|
||||||
let run = |stack: usize| {
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.stack_size(stack)
|
|
||||||
.spawn(|| {
|
|
||||||
// Row-major order calculates A1 first, so each cell below is calculated on demand
|
|
||||||
// at the deepest call of the one above it.
|
|
||||||
let countdown = |then: &str| format!("=LET(F,LAMBDA(me,n,IF(n<=0,{then},1+me(me,n-1))),F(F,131))");
|
|
||||||
let mut wb = Workbook::new();
|
|
||||||
let sheet = wb.sheet_mut(0).expect("sheet");
|
|
||||||
for (at, f) in [
|
|
||||||
("A1", countdown("A2")),
|
|
||||||
("A2", countdown("A3")),
|
|
||||||
("A3", countdown("0")),
|
|
||||||
("B1", countdown("B2")),
|
|
||||||
("B2", "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))".to_string()),
|
|
||||||
] {
|
|
||||||
sheet.cells.set(c(at), Cell::formula(Formula::new(&f)));
|
|
||||||
}
|
|
||||||
let mut calc = Calc::new();
|
|
||||||
calc.recalc_all(&mut wb);
|
|
||||||
["A1", "A2", "A3", "B1", "B2"].map(|at| wb.sheet(0).map(|s| s.value(c(at))).unwrap_or_default())
|
|
||||||
})
|
|
||||||
.expect("spawn")
|
|
||||||
.join()
|
|
||||||
.expect("recursion must not overflow the stack")
|
|
||||||
};
|
|
||||||
let small = run(1 << 20);
|
|
||||||
let num = Value::Error(CellError::Num);
|
|
||||||
assert_eq!(small, [Value::Number(393.0), Value::Number(262.0), Value::Number(131.0), num.clone(), num]);
|
|
||||||
assert_eq!(run(64 << 20), small);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn names_and_sheets() {
|
fn names_and_sheets() {
|
||||||
let mut t = T::new();
|
let mut t = T::new();
|
||||||
|
|||||||
@@ -160,28 +160,21 @@ fn stroke_paths(pts: &[[f32; 2]], width: f32, dash: bool, closed: bool) -> Vec<P
|
|||||||
segs.push((*a, *b));
|
segs.push((*a, *b));
|
||||||
}
|
}
|
||||||
if dash {
|
if dash {
|
||||||
// The per-segment dash cap bounds each input line, but a caller-supplied vector of many
|
|
||||||
// long dashed lines multiplies: together they would eagerly allocate megabytes of
|
|
||||||
// geometry even when nothing of it reaches the canvas, so the total is capped too and
|
|
||||||
// the line is drawn solid once the budget is gone.
|
|
||||||
let (on, off) = (width * 4.0, width * 3.0);
|
let (on, off) = (width * 4.0, width * 3.0);
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut budget = 65_536usize;
|
|
||||||
for (a, b) in segs {
|
for (a, b) in segs {
|
||||||
if budget == 0 {
|
|
||||||
return vec![vec![pts.to_vec()]];
|
|
||||||
}
|
|
||||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||||
let len = (dx * dx + dy * dy).sqrt();
|
let len = (dx * dx + dy * dy).sqrt();
|
||||||
if !(len.is_finite() && len > 0.0) {
|
if !(len.is_finite() && len > 0.0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut t = 0.0;
|
let mut t = 0.0;
|
||||||
while t < len && budget > 0 {
|
let mut n = 0;
|
||||||
|
while t < len && n < 10_000 {
|
||||||
let e = (t + on).min(len);
|
let e = (t + on).min(len);
|
||||||
out.push(([a[0] + dx * t / len, a[1] + dy * t / len], [a[0] + dx * e / len, a[1] + dy * e / len]));
|
out.push(([a[0] + dx * t / len, a[1] + dy * t / len], [a[0] + dx * e / len, a[1] + dy * e / len]));
|
||||||
t += on + off;
|
t += on + off;
|
||||||
budget -= 1;
|
n += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
segs = out;
|
segs = out;
|
||||||
@@ -269,23 +262,3 @@ pub fn rasterize(prims: &[Prim], w: u32, h: u32, background: Rgba) -> Vec<u8> {
|
|||||||
}
|
}
|
||||||
cv.buf
|
cv.buf
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dashed_geometry_stays_within_the_dash_budget() {
|
|
||||||
// Forty very long dashed lines, each allowed 10_000 dashes per segment: before the
|
|
||||||
// shared cap they eagerly allocated ~1.6 MB of segment pairs × points and a like
|
|
||||||
// number of quads before any of it reached a 4-pixel canvas (finding: dash expansion).
|
|
||||||
let prims: Vec<Prim> =
|
|
||||||
(0..40).map(|i| Prim::Line { pts: vec![[0.0, i as f32], [900.0, i as f32]], color: [0, 0, 0, 255], width: 1.0, dash: true }).collect();
|
|
||||||
let img = rasterize(&prims, 2, 2, [255, 255, 255, 255]);
|
|
||||||
assert_eq!(img.len(), 2 * 2 * 4);
|
|
||||||
// And the geometry itself is bounded even for one huge dashed polyline.
|
|
||||||
assert!(stroke_paths(&[[0.0, 0.0], [1e6, 0.0]], 1.0, true, false).len() <= 65_536);
|
|
||||||
let prim = [Prim::Line { pts: vec![[0.0, 0.0], [10.0, 0.0]], color: [0, 0, 0, 255], width: 1.0, dash: true }];
|
|
||||||
assert_eq!(rasterize(&prim, 1, 1, [0, 0, 0, 255]).len(), 4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+15
-31
@@ -425,7 +425,7 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
|
|||||||
let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max);
|
let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max);
|
||||||
if !horizontal {
|
if !horizontal {
|
||||||
let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4);
|
let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4);
|
||||||
let y2labels = y2.map(ValAxis::labels);
|
let y2labels = y2.map(|a| a.labels());
|
||||||
let right = match (&y2labels, &cat) {
|
let right = match (&y2labels, &cat) {
|
||||||
(Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3),
|
(Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3),
|
||||||
(None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2),
|
(None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2),
|
||||||
@@ -868,7 +868,6 @@ enum Fam {
|
|||||||
LineStack,
|
LineStack,
|
||||||
Area,
|
Area,
|
||||||
AreaStack,
|
AreaStack,
|
||||||
Area100,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn family(k: ChartKind) -> Fam {
|
fn family(k: ChartKind) -> Fam {
|
||||||
@@ -880,16 +879,10 @@ fn family(k: ChartKind) -> Fam {
|
|||||||
LineStacked => Fam::LineStack,
|
LineStacked => Fam::LineStack,
|
||||||
Area => Fam::Area,
|
Area => Fam::Area,
|
||||||
AreaStacked => Fam::AreaStack,
|
AreaStacked => Fam::AreaStack,
|
||||||
AreaStacked100 => Fam::Area100,
|
|
||||||
_ => Fam::Col,
|
_ => Fam::Col,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Families stacked to 100%: each category's values are shares of its total.
|
|
||||||
fn is_100(f: Fam) -> bool {
|
|
||||||
matches!(f, Fam::Col100 | Fam::Area100)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_col(f: Fam) -> bool {
|
fn is_col(f: Fam) -> bool {
|
||||||
matches!(f, Fam::Col | Fam::ColStack | Fam::Col100)
|
matches!(f, Fam::Col | Fam::ColStack | Fam::Col100)
|
||||||
}
|
}
|
||||||
@@ -907,38 +900,25 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
|||||||
let any_primary = data.series.iter().any(|s| !s.secondary);
|
let any_primary = data.series.iter().any(|s| !s.secondary);
|
||||||
let sec: Vec<bool> = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect();
|
let sec: Vec<bool> = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect();
|
||||||
|
|
||||||
// Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart).
|
// Totals for 100% stacking, per axis.
|
||||||
let mut totals: Vec<((bool, Fam), Vec<f64>)> = Vec::new();
|
let mut totals = [vec![0.0f64; ncat], vec![0.0f64; ncat]];
|
||||||
for (i, s) in data.series.iter().enumerate() {
|
for (i, s) in data.series.iter().enumerate() {
|
||||||
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
|
if fams.get(i) == Some(&Fam::Col100) {
|
||||||
if is_100(fam) {
|
let a = sec.get(i).copied().unwrap_or(false) as usize;
|
||||||
let key = (sec.get(i).copied().unwrap_or(false), fam);
|
|
||||||
let p = match totals.iter().position(|(k, _)| *k == key) {
|
|
||||||
Some(p) => p,
|
|
||||||
None => {
|
|
||||||
totals.push((key, vec![0.0; ncat]));
|
|
||||||
totals.len() - 1
|
|
||||||
}
|
|
||||||
};
|
|
||||||
for (j, v) in s.values.iter().take(ncat).enumerate() {
|
for (j, v) in s.values.iter().take(ncat).enumerate() {
|
||||||
if let (Some(v), Some(t)) = (v, totals.get_mut(p).and_then(|(_, t)| t.get_mut(j))) {
|
if let (Some(v), Some(t)) = (v, totals.get_mut(a).and_then(|t| t.get_mut(j))) {
|
||||||
*t += v.abs();
|
*t += v.abs();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A value's share of its category's total, for the 100% families.
|
|
||||||
let share = |key: (bool, Fam), j: usize, v: f64| {
|
|
||||||
let t = totals.iter().find(|(k, _)| *k == key).and_then(|(_, t)| t.get(j)).copied().unwrap_or(0.0);
|
|
||||||
if t > 0.0 { v / t } else { 0.0 }
|
|
||||||
};
|
|
||||||
// Stack accumulators keyed by (axis, family): positive and negative running sums.
|
// Stack accumulators keyed by (axis, family): positive and negative running sums.
|
||||||
let mut stacks: Vec<((bool, Fam), Vec<f64>, Vec<f64>)> = Vec::new();
|
let mut stacks: Vec<((bool, Fam), Vec<f64>, Vec<f64>)> = Vec::new();
|
||||||
let mut spans: Vec<Vec<Option<(f64, f64)>>> = Vec::with_capacity(data.series.len());
|
let mut spans: Vec<Vec<Option<(f64, f64)>>> = Vec::with_capacity(data.series.len());
|
||||||
for (i, s) in data.series.iter().enumerate() {
|
for (i, s) in data.series.iter().enumerate() {
|
||||||
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
|
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
|
||||||
let a = sec.get(i).copied().unwrap_or(false);
|
let a = sec.get(i).copied().unwrap_or(false);
|
||||||
let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack | Fam::Area100);
|
let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack);
|
||||||
let key = (a, fam);
|
let key = (a, fam);
|
||||||
let si = if stacked {
|
let si = if stacked {
|
||||||
match stacks.iter().position(|(k, _, _)| *k == key) {
|
match stacks.iter().position(|(k, _, _)| *k == key) {
|
||||||
@@ -956,7 +936,12 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
|||||||
let v = s.values.get(j).copied().flatten();
|
let v = s.values.get(j).copied().flatten();
|
||||||
let span = match (si.and_then(|p| stacks.get_mut(p)), fam) {
|
let span = match (si.and_then(|p| stacks.get_mut(p)), fam) {
|
||||||
(Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| {
|
(Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| {
|
||||||
let v = if fam == Fam::Col100 { share(key, j, v) } else { v };
|
let v = if fam == Fam::Col100 {
|
||||||
|
let t = totals.get(a as usize).and_then(|t| t.get(j)).copied().unwrap_or(0.0);
|
||||||
|
if t > 0.0 { v / t } else { 0.0 }
|
||||||
|
} else {
|
||||||
|
v
|
||||||
|
};
|
||||||
let acc = if v >= 0.0 { pos.get_mut(j) } else { neg.get_mut(j) }?;
|
let acc = if v >= 0.0 { pos.get_mut(j) } else { neg.get_mut(j) }?;
|
||||||
let base = *acc;
|
let base = *acc;
|
||||||
*acc += v;
|
*acc += v;
|
||||||
@@ -964,7 +949,6 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
|||||||
}),
|
}),
|
||||||
(Some((_, pos, _)), _) => {
|
(Some((_, pos, _)), _) => {
|
||||||
let v = v.unwrap_or(0.0);
|
let v = v.unwrap_or(0.0);
|
||||||
let v = if fam == Fam::Area100 { share(key, j, v) } else { v };
|
|
||||||
pos.get_mut(j).map(|acc| {
|
pos.get_mut(j).map(|acc| {
|
||||||
let base = *acc;
|
let base = *acc;
|
||||||
*acc = clean(*acc + v);
|
*acc = clean(*acc + v);
|
||||||
@@ -986,7 +970,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let force_zero = members.iter().any(|i| fams.get(*i).is_some_and(|f| !is_line(*f)));
|
let force_zero = members.iter().any(|i| fams.get(*i).is_some_and(|f| !is_line(*f)));
|
||||||
let all100 = members.iter().all(|i| fams.get(*i).is_some_and(|f| is_100(*f)));
|
let all100 = members.iter().all(|i| fams.get(*i) == Some(&Fam::Col100));
|
||||||
let range = value_range(members.iter().flat_map(|i| {
|
let range = value_range(members.iter().flat_map(|i| {
|
||||||
let line = fams.get(*i).is_some_and(|f| is_line(*f));
|
let line = fams.get(*i).is_some_and(|f| is_line(*f));
|
||||||
spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] })
|
spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] })
|
||||||
@@ -1060,7 +1044,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
|||||||
for (i, s) in data.series.iter().enumerate() {
|
for (i, s) in data.series.iter().enumerate() {
|
||||||
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
|
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
|
||||||
let want = match pass {
|
let want = match pass {
|
||||||
0 => matches!(fam, Fam::Area | Fam::AreaStack | Fam::Area100),
|
0 => matches!(fam, Fam::Area | Fam::AreaStack),
|
||||||
1 => is_col(fam),
|
1 => is_col(fam),
|
||||||
_ => is_line(fam),
|
_ => is_line(fam),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use gridcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, Series, Sparkl
|
|||||||
|
|
||||||
use crate::*;
|
use crate::*;
|
||||||
|
|
||||||
const ALL_KINDS: [ChartKind; 26] = [
|
const ALL_KINDS: [ChartKind; 25] = [
|
||||||
ChartKind::ColumnClustered,
|
ChartKind::ColumnClustered,
|
||||||
ChartKind::ColumnStacked,
|
ChartKind::ColumnStacked,
|
||||||
ChartKind::ColumnStacked100,
|
ChartKind::ColumnStacked100,
|
||||||
@@ -19,7 +19,6 @@ const ALL_KINDS: [ChartKind; 26] = [
|
|||||||
ChartKind::Doughnut,
|
ChartKind::Doughnut,
|
||||||
ChartKind::Area,
|
ChartKind::Area,
|
||||||
ChartKind::AreaStacked,
|
ChartKind::AreaStacked,
|
||||||
ChartKind::AreaStacked100,
|
|
||||||
ChartKind::Scatter,
|
ChartKind::Scatter,
|
||||||
ChartKind::ScatterLines,
|
ChartKind::ScatterLines,
|
||||||
ChartKind::Bubble,
|
ChartKind::Bubble,
|
||||||
@@ -77,7 +76,7 @@ fn sample(kind: ChartKind) -> ChartData {
|
|||||||
ChartData {
|
ChartData {
|
||||||
category_levels: vec![],
|
category_levels: vec![],
|
||||||
palette: vec![],
|
palette: vec![],
|
||||||
categories: cats.iter().map(ToString::to_string).collect(),
|
categories: cats.iter().map(|s| s.to_string()).collect(),
|
||||||
series: vec![
|
series: vec![
|
||||||
series("North", &[12000.0, -4000.0, 23000.0, 31000.0, 18000.0, 27000.0], [20, 96, 130, 255], k1),
|
series("North", &[12000.0, -4000.0, 23000.0, 31000.0, 18000.0, 27000.0], [20, 96, 130, 255], k1),
|
||||||
b,
|
b,
|
||||||
@@ -1074,7 +1073,7 @@ fn pie_points_follow_workbook_theme() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn area_charts_draw_source_value_labels_only_when_enabled() {
|
fn area_charts_draw_source_value_labels_only_when_enabled() {
|
||||||
for kind in [ChartKind::Area, ChartKind::AreaStacked, ChartKind::AreaStacked100] {
|
for kind in [ChartKind::Area, ChartKind::AreaStacked] {
|
||||||
let d = sample(kind);
|
let d = sample(kind);
|
||||||
let mut c = chart(kind);
|
let mut c = chart(kind);
|
||||||
c.data_labels = false;
|
c.data_labels = false;
|
||||||
@@ -1098,45 +1097,6 @@ fn area_charts_draw_source_value_labels_only_when_enabled() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hundred_percent_stacked_areas_fill_each_category_to_its_total() {
|
|
||||||
let d = ChartData {
|
|
||||||
category_levels: vec![],
|
|
||||||
palette: vec![],
|
|
||||||
categories: ["Q1", "Q2", "Q3", "Q4"].iter().map(|s| s.to_string()).collect(),
|
|
||||||
series: vec![
|
|
||||||
series("North", &[10.0, 30.0, 0.0, 5.0], [20, 96, 130, 255], ChartKind::AreaStacked100),
|
|
||||||
series("South", &[30.0, 10.0, 0.0, 15.0], [25, 107, 36, 255], ChartKind::AreaStacked100),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
let mut c = chart(ChartKind::AreaStacked100);
|
|
||||||
c.data_labels = false;
|
|
||||||
let p = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
|
|
||||||
let t = texts(&p);
|
|
||||||
assert!(t.contains(&"100%") && t.contains(&"0%"), "{t:?}");
|
|
||||||
assert!(!t.contains(&"40"), "{t:?}");
|
|
||||||
let polys: Vec<&Vec<[f32; 2]>> = p
|
|
||||||
.iter()
|
|
||||||
.filter_map(|p| match p {
|
|
||||||
Prim::Polygon { pts, .. } => Some(pts),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let (Some(north), Some(south)) = (polys.first(), polys.get(1)) else { panic!("two areas expected: {polys:?}") };
|
|
||||||
// Each polygon is its top edge left to right, then its base right to left.
|
|
||||||
let top = |poly: &[[f32; 2]], j: usize| poly.get(j).map(|p| p[1]).unwrap_or(f32::NAN);
|
|
||||||
let base = |poly: &[[f32; 2]], j: usize| poly.get(poly.len() - 1 - j).map(|p| p[1]).unwrap_or(f32::NAN);
|
|
||||||
let (y0, y100) = (base(north, 0), top(south, 0));
|
|
||||||
assert!(y0 > y100, "{y0} {y100}");
|
|
||||||
let frac = |y: f32| (y0 - y) / (y0 - y100);
|
|
||||||
for (j, share) in [(0, 0.25), (1, 0.75), (3, 0.25)] {
|
|
||||||
assert!((frac(top(north, j)) - share).abs() < 1e-3, "Q{}: North reaches {}", j + 1, frac(top(north, j)));
|
|
||||||
assert!((frac(top(south, j)) - 1.0).abs() < 1e-3, "Q{}: South reaches {}", j + 1, frac(top(south, j)));
|
|
||||||
}
|
|
||||||
// A category whose total is zero stays on the baseline.
|
|
||||||
assert!(frac(top(south, 2)).abs() < 1e-3, "Q3: South reaches {}", frac(top(south, 2)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn two_level_categories_resolve_and_draw() {
|
fn two_level_categories_resolve_and_draw() {
|
||||||
let years = ["2023", "", "", "2024", "", ""];
|
let years = ["2023", "", "", "2024", "", ""];
|
||||||
|
|||||||
+1
-16
@@ -246,7 +246,7 @@ fn is_r1c1_token(name: &str) -> bool {
|
|||||||
if chars.peek().is_some_and(|c| c.to_ascii_lowercase() == letter) {
|
if chars.peek().is_some_and(|c| c.to_ascii_lowercase() == letter) {
|
||||||
chars.next();
|
chars.next();
|
||||||
letters += 1;
|
letters += 1;
|
||||||
while chars.peek().is_some_and(char::is_ascii_digit) {
|
while chars.peek().is_some_and(|c| c.is_ascii_digit()) {
|
||||||
chars.next();
|
chars.next();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -350,21 +350,6 @@ mod tests {
|
|||||||
assert_eq!(split_sheet("A1"), (None, "A1"));
|
assert_eq!(split_sheet("A1"), (None, "A1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #2312: device/backslash garbage in front of `!` is cosmetic at worst — `split_sheet` only
|
|
||||||
/// splits at `!`, and every consumer matches the sheet part against workbook sheet names (it
|
|
||||||
/// never reaches a filesystem path or a shell); the body half always stays a pure cell/range
|
|
||||||
/// reference.
|
|
||||||
#[test]
|
|
||||||
fn split_sheet_leaves_device_names_inert_in_the_body() {
|
|
||||||
for s in ["C:\\a\\file!A1", "CON!A1", "NUL!A1", "a[b]!A1"] {
|
|
||||||
let (sheet, rest) = split_sheet(s);
|
|
||||||
assert_eq!(CellRef::parse(rest), Some(CellRef::new(0, 0)), "{s:?}: body must stay a bare reference");
|
|
||||||
let _ = sheet;
|
|
||||||
}
|
|
||||||
// A plain single-letter sheet is still split, not mistaken for a body.
|
|
||||||
assert_eq!(split_sheet("C!A1").0, Some("C".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn letters_roundtrip() {
|
fn letters_roundtrip() {
|
||||||
for (c, s) in [(0, "A"), (25, "Z"), (26, "AA"), (51, "AZ"), (52, "BA"), (701, "ZZ"), (702, "AAA"), (16383, "XFD")] {
|
for (c, s) in [(0, "A"), (25, "Z"), (26, "AA"), (51, "AZ"), (52, "BA"), (701, "ZZ"), (702, "AAA"), (16383, "XFD")] {
|
||||||
|
|||||||
@@ -371,16 +371,10 @@ fn parse_datetime(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>
|
|||||||
if let Some((d, f)) = parse_date(t, sys) {
|
if let Some((d, f)) = parse_date(t, sys) {
|
||||||
return Some((d, Some(f)));
|
return Some((d, Some(f)));
|
||||||
}
|
}
|
||||||
// date + time: split at the last space(s) before a time-looking tail. Only splits where the
|
// date + time: split at the last space(s) before a time-looking tail.
|
||||||
// tail could be a time are tried: a long tail can't parse, so this stays linear in the text
|
|
||||||
// instead of reparsing every prefix for every space (one long text field quadratic).
|
|
||||||
for (i, _) in t.match_indices(' ') {
|
for (i, _) in t.match_indices(' ') {
|
||||||
let Some(b) = t.get(i + 1..) else { continue };
|
let (a, b) = (t.get(..i)?, t.get(i + 1..)?);
|
||||||
let bytes = b.as_bytes();
|
if let (Some((d, _)), Some((tf, _, _))) = (parse_date(a, sys), parse_time(b)) {
|
||||||
if b.len() > 36 || !bytes.first().is_some_and(u8::is_ascii_digit) || !b.contains(':') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let (Some((d, _)), Some((tf, _, _))) = (t.get(..i).and_then(|a| parse_date(a, sys)), parse_time(b)) {
|
|
||||||
return Some((d + tf, Some("m/d/yyyy h:mm")));
|
return Some((d + tf, Some("m/d/yyyy h:mm")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -406,20 +400,6 @@ mod tests {
|
|||||||
assert_eq!(parse_number_text("1 1/2"), Some(1.5));
|
assert_eq!(parse_number_text("1 1/2"), Some(1.5));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn overlong_time_fields_stay_text() {
|
|
||||||
// #2168: all-digit field lengths are gated before allocation/derivation,
|
|
||||||
// so hostile strings never spin the time-derivation machinery.
|
|
||||||
let big = "9".repeat(500);
|
|
||||||
for s in [format!("{big}:00"), format!("1:00:{big}"), format!("{big}:{big}:{big}"), format!("1:0{big}"), format!("00:00:{big}.1")] {
|
|
||||||
assert_eq!(p(&s).value, Value::text(s.as_str()), "{s}");
|
|
||||||
assert_eq!(parse_number_text(&s), None, "{s}");
|
|
||||||
}
|
|
||||||
// Within the gate, times still work.
|
|
||||||
assert_eq!(p("01:02:03").value, Value::number(3.0 / 86_400.0 + 1.0 / 24.0 + 2.0 / 1440.0));
|
|
||||||
assert_eq!(parse_number_text("13:14:15"), Some(0.5515625));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_digit_time_and_fraction_parts_stay_text() {
|
fn non_digit_time_and_fraction_parts_stay_text() {
|
||||||
for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] {
|
for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] {
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ impl Value {
|
|||||||
Value::Bool(b) => Ok(if *b { "TRUE".into() } else { "FALSE".into() }),
|
Value::Bool(b) => Ok(if *b { "TRUE".into() } else { "FALSE".into() }),
|
||||||
Value::Text(t) => Ok(t.to_string()),
|
Value::Text(t) => Ok(t.to_string()),
|
||||||
Value::Error(e) => Err(*e),
|
Value::Error(e) => Err(*e),
|
||||||
Value::Array(a) => a.data.first().map(Value::to_text).unwrap_or(Ok(String::new())),
|
Value::Array(a) => a.data.first().map(|v| v.to_text()).unwrap_or(Ok(String::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Coercion to a boolean as `IF` does it.
|
/// Coercion to a boolean as `IF` does it.
|
||||||
@@ -264,7 +264,7 @@ impl Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Value::Error(e) => Err(*e),
|
Value::Error(e) => Err(*e),
|
||||||
Value::Array(a) => a.data.first().map(Value::to_bool).unwrap_or(Ok(false)),
|
Value::Array(a) => a.data.first().map(|v| v.to_bool()).unwrap_or(Ok(false)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Display text with General formatting (for logs, CSV and tests).
|
/// Display text with General formatting (for logs, CSV and tests).
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use gridcraft_core::{CellRef, RangeRef, Value, compare};
|
use gridcraft_core::{CellRef, RangeRef, Value, compare};
|
||||||
use gridcraft_model::{CfOperator, CfRule, CfValueKind, Color, CondFormat, PatternType, Sheet, Style, Underline, Workbook, default_icon_thresholds};
|
use gridcraft_model::*;
|
||||||
|
|
||||||
/// The conditional look of one cell.
|
/// The conditional look of one cell.
|
||||||
#[derive(Clone, Debug, Default, PartialEq)]
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
|
|||||||
@@ -106,15 +106,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
has_doc,
|
has_doc,
|
||||||
show_levels
|
show_levels
|
||||||
),
|
),
|
||||||
cmd!(
|
|
||||||
"data.outlineSettings",
|
|
||||||
"Outline Settings",
|
|
||||||
["Data", "Outline"],
|
|
||||||
None,
|
|
||||||
"{sheet?, summaryBelow?: bool, summaryRight?: bool} (summary rows below their detail, summary columns right of it; off puts them above / left) → {summaryBelow, summaryRight}",
|
|
||||||
has_doc,
|
|
||||||
outline_settings
|
|
||||||
),
|
|
||||||
cmd!(
|
cmd!(
|
||||||
"data.subtotal",
|
"data.subtotal",
|
||||||
"Subtotal",
|
"Subtotal",
|
||||||
@@ -1338,7 +1329,6 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
|
|||||||
let (rows, a, b) = outline_lines(s, p)?;
|
let (rows, a, b) = outline_lines(s, p)?;
|
||||||
edit(s, |cx| {
|
edit(s, |cx| {
|
||||||
let sh = cx.sheet_mut(si)?;
|
let sh = cx.sheet_mut(si)?;
|
||||||
let before = outline::summary_before(sh, rows);
|
|
||||||
let map = if rows { &mut sh.rows } else { &mut sh.cols };
|
let map = if rows { &mut sh.rows } else { &mut sh.cols };
|
||||||
let b = b.min(a.saturating_add(1_048_575));
|
let b = b.min(a.saturating_add(1_048_575));
|
||||||
for i in a..=b {
|
for i in a..=b {
|
||||||
@@ -1349,10 +1339,9 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A summary line whose group is gone has nothing left to collapse.
|
// A summary line whose group is gone has nothing left to collapse.
|
||||||
let summary = if before { a.checked_sub(1) } else { b.checked_add(1) };
|
|
||||||
if !on
|
if !on
|
||||||
&& !outline::groups_with(map, before).iter().any(|g| if before { g.start == a } else { g.end == b })
|
&& !outline::groups(map).iter().any(|g| g.end == b)
|
||||||
&& let Some(e) = summary.and_then(|i| map.get_mut(&i))
|
&& let Some(e) = map.get_mut(&b.saturating_add(1))
|
||||||
{
|
{
|
||||||
e.collapsed = false;
|
e.collapsed = false;
|
||||||
}
|
}
|
||||||
@@ -1370,9 +1359,8 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
|
|||||||
edit(s, |cx| {
|
edit(s, |cx| {
|
||||||
let si = cx.sheet_index();
|
let si = cx.sheet_index();
|
||||||
let sh = cx.sheet_mut(si)?;
|
let sh = cx.sheet_mut(si)?;
|
||||||
let before = outline::summary_before(sh, rows);
|
|
||||||
let map = if rows { &mut sh.rows } else { &mut sh.cols };
|
let map = if rows { &mut sh.rows } else { &mut sh.cols };
|
||||||
let all = outline::groups_with(map, before);
|
let all = outline::groups(map);
|
||||||
let level_ok = |g: &outline::OutlineGroup| want.is_none_or(|l| g.level == l);
|
let level_ok = |g: &outline::OutlineGroup| want.is_none_or(|l| g.level == l);
|
||||||
let pick = |hit: &dyn Fn(&outline::OutlineGroup) -> bool| -> Vec<outline::OutlineGroup> {
|
let pick = |hit: &dyn Fn(&outline::OutlineGroup) -> bool| -> Vec<outline::OutlineGroup> {
|
||||||
let hits: Vec<_> = all.iter().filter(|g| level_ok(g) && hit(g)).copied().collect();
|
let hits: Vec<_> = all.iter().filter(|g| level_ok(g) && hit(g)).copied().collect();
|
||||||
@@ -1381,13 +1369,13 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
|
|||||||
};
|
};
|
||||||
let mut chosen = pick(&|g| g.start <= b && g.end >= a);
|
let mut chosen = pick(&|g| g.start <= b && g.end >= a);
|
||||||
if chosen.is_empty() {
|
if chosen.is_empty() {
|
||||||
chosen = pick(&|g| g.has_summary() && g.summary() >= a && g.summary() <= b);
|
chosen = pick(&|g| g.summary() >= a && g.summary() <= b);
|
||||||
}
|
}
|
||||||
for g in chosen {
|
for g in chosen {
|
||||||
// Inner groups collapsed on their own (their summary marked), before this one changes.
|
// Inner groups collapsed on their own (their summary marked), before this one changes.
|
||||||
let inner: Vec<outline::OutlineGroup> = all
|
let inner: Vec<outline::OutlineGroup> = all
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.has_summary() && o.summary() != g.summary())
|
.filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.summary() != g.summary())
|
||||||
.filter(|o| map.get(&o.summary()).is_some_and(|e| e.collapsed))
|
.filter(|o| map.get(&o.summary()).is_some_and(|e| e.collapsed))
|
||||||
.copied()
|
.copied()
|
||||||
.collect();
|
.collect();
|
||||||
@@ -1396,9 +1384,6 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
|
|||||||
set_hidden(e, hide || inner.iter().any(|o| o.contains(i)));
|
set_hidden(e, hide || inner.iter().any(|o| o.contains(i)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !g.has_summary() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if hide {
|
if hide {
|
||||||
map.entry(g.summary()).or_default().collapsed = true;
|
map.entry(g.summary()).or_default().collapsed = true;
|
||||||
} else if let Some(e) = map.get_mut(&g.summary()) {
|
} else if let Some(e) = map.get_mut(&g.summary()) {
|
||||||
@@ -1434,16 +1419,15 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
|
|||||||
edit(s, |cx| {
|
edit(s, |cx| {
|
||||||
let si = cx.sheet_index();
|
let si = cx.sheet_index();
|
||||||
let sh = cx.sheet_mut(si)?;
|
let sh = cx.sheet_mut(si)?;
|
||||||
let before = (outline::summary_before(sh, true), outline::summary_before(sh, false));
|
for (map, n) in [(&mut sh.rows, rows), (&mut sh.cols, cols)] {
|
||||||
for (map, n, before) in [(&mut sh.rows, rows, before.0), (&mut sh.cols, cols, before.1)] {
|
|
||||||
let Some(n) = n else { continue };
|
let Some(n) = n else { continue };
|
||||||
let groups = outline::groups_with(map, before);
|
let groups = outline::groups(map);
|
||||||
for e in map.values_mut().filter(|e| e.outline > 0) {
|
for e in map.values_mut().filter(|e| e.outline > 0) {
|
||||||
set_hidden(e, e.outline >= n);
|
set_hidden(e, e.outline >= n);
|
||||||
}
|
}
|
||||||
// Inner groups come first, so a summary line shared with the group around it ends up
|
// Inner groups come first, so a summary line shared with the group around it ends up
|
||||||
// as that outer group says.
|
// as that outer group says.
|
||||||
for g in groups.into_iter().filter(|g| g.has_summary()) {
|
for g in groups {
|
||||||
map.entry(g.summary()).or_default().collapsed = g.level >= n;
|
map.entry(g.summary()).or_default().collapsed = g.level >= n;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1452,53 +1436,6 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Data ▸ Outline ▸ Settings: whether summary rows sit below their detail and summary columns
|
|
||||||
/// right of it. A changed direction moves each collapsed group's mark (+) to its new summary line.
|
|
||||||
fn outline_settings(s: &mut Session, p: &Json) -> Result<Json> {
|
|
||||||
let si = target_sheet(s, p)?;
|
|
||||||
edit(s, |cx| {
|
|
||||||
let sh = cx.sheet_mut(si)?;
|
|
||||||
for (rows, key) in [(true, "summaryBelow"), (false, "summaryRight")] {
|
|
||||||
let Some(on) = bool_param(p, key) else { continue };
|
|
||||||
let was_before = outline::summary_before(sh, rows);
|
|
||||||
if rows {
|
|
||||||
sh.summary_below = on;
|
|
||||||
} else {
|
|
||||||
sh.summary_right = on;
|
|
||||||
}
|
|
||||||
if was_before != on {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let map = if rows { &mut sh.rows } else { &mut sh.cols };
|
|
||||||
let mut marked: Vec<outline::OutlineGroup> = outline::groups_with(map, was_before)
|
|
||||||
.into_iter()
|
|
||||||
.filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed))
|
|
||||||
.collect();
|
|
||||||
// A mark on a line that nested groups share moves with the outermost of them whose
|
|
||||||
// detail is all hidden, or with the outermost when none is.
|
|
||||||
let mut owner: std::collections::BTreeMap<u32, (bool, u8)> = std::collections::BTreeMap::new();
|
|
||||||
for g in &marked {
|
|
||||||
let key = (!g.collapsed, g.level);
|
|
||||||
let best = owner.entry(g.summary()).or_insert(key);
|
|
||||||
*best = (*best).min(key);
|
|
||||||
}
|
|
||||||
marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level)));
|
|
||||||
for g in &marked {
|
|
||||||
if let Some(e) = map.get_mut(&g.summary()) {
|
|
||||||
e.collapsed = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for g in marked {
|
|
||||||
let g = outline::OutlineGroup { summary_before: !on, ..g };
|
|
||||||
if g.has_summary() {
|
|
||||||
map.entry(g.summary()).or_default().collapsed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right}))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
|
fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
let (sheet, r) = data_target(s, p, "data.subtotal")?;
|
let (sheet, r) = data_target(s, p, "data.subtotal")?;
|
||||||
let header = bool_param(p, "header").unwrap_or(true);
|
let header = bool_param(p, "header").unwrap_or(true);
|
||||||
@@ -1539,11 +1476,6 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
|
|||||||
start = row;
|
start = row;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The totals go below each group, so the outline puts summary rows below their detail (the
|
|
||||||
// Subtotal dialog's "Summary below data"), in the same undo step.
|
|
||||||
if !sh.summary_below {
|
|
||||||
s.execute("data.outlineSettings", json!({"sheet": sheet, "summaryBelow": true}))?;
|
|
||||||
}
|
|
||||||
// Insert a total row after each group, bottom-up so indices stay valid.
|
// Insert a total row after each group, bottom-up so indices stay valid.
|
||||||
let mut inserted = 0;
|
let mut inserted = 0;
|
||||||
for (g0, g1, name) in groups.iter().rev() {
|
for (g0, g1, name) in groups.iter().rev() {
|
||||||
@@ -1608,28 +1540,6 @@ mod tests {
|
|||||||
assert_eq!(get(&mut s, "B7")["value"], json!(100.0), "the grand total counts every data row");
|
assert_eq!(get(&mut s, "B7")["value"], json!(100.0), "the grand total counts every data row");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn subtotal_puts_summary_rows_below_their_detail() {
|
|
||||||
let mut s = Session::new();
|
|
||||||
s.new_workbook();
|
|
||||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 10], ["East", 20], ["West", 30]]})).unwrap();
|
|
||||||
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
|
|
||||||
s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "columns": ["B"]})).unwrap();
|
|
||||||
let sh = s.doc().unwrap().wb.active().unwrap().clone();
|
|
||||||
assert!(sh.summary_below, "the totals sit below their groups");
|
|
||||||
// East's detail is rows 2–3 and its total row 4: collapsing level 1 keeps the total showing.
|
|
||||||
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
|
|
||||||
let hidden = |s: &Session, row: u32| s.doc().unwrap().wb.active().unwrap().is_row_hidden(row);
|
|
||||||
assert!(hidden(&s, 1) && hidden(&s, 2), "East's detail rows are hidden");
|
|
||||||
assert!(!hidden(&s, 3), "East Total stays visible");
|
|
||||||
// One undo takes the subtotals and the setting back.
|
|
||||||
s.execute("edit.undo", json!({})).unwrap();
|
|
||||||
s.execute("edit.undo", json!({})).unwrap();
|
|
||||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
|
||||||
assert!(!sh.summary_below);
|
|
||||||
assert_eq!(sh.value(CellRef::new(3, 0)).display(), "West");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn filter_by_huge_numeric_column_is_rejected_not_overflowed() {
|
fn filter_by_huge_numeric_column_is_rejected_not_overflowed() {
|
||||||
let mut s = Session::new();
|
let mut s = Session::new();
|
||||||
@@ -1861,73 +1771,6 @@ mod tests {
|
|||||||
assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left"));
|
assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn summary_rows_above_the_detail_take_the_collapsed_mark() {
|
|
||||||
let mut s = nested_rows();
|
|
||||||
let r = s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
|
|
||||||
assert_eq!(r, json!({"summaryBelow": false, "summaryRight": true}));
|
|
||||||
// Row 4 (index 3) is the outer group's summary, row 5 the inner one's.
|
|
||||||
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
|
|
||||||
assert_eq!(row_state(&s, 3..=8), [(false, true), (true, false), (true, false), (true, false), (true, false), (false, false)]);
|
|
||||||
s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap();
|
|
||||||
assert_eq!(row_state(&s, 3..=8), [(false, false); 6]);
|
|
||||||
// Hide Detail on the summary row collapses the group below it.
|
|
||||||
s.execute("data.hideDetail", json!({"rows": "4:4"})).unwrap();
|
|
||||||
assert_eq!(row_state(&s, 3..=4), [(false, true), (true, false)]);
|
|
||||||
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
|
|
||||||
assert_eq!(row_state(&s, 3..=8), [(false, true), (true, true), (true, false), (true, false), (true, false), (false, false)]);
|
|
||||||
// Ungrouping the outer group clears its mark on the row above.
|
|
||||||
s.execute("data.showLevels", json!({"rows": 3})).unwrap();
|
|
||||||
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
|
|
||||||
s.execute("data.ungroup", json!({"rows": "5:8"})).unwrap();
|
|
||||||
assert_eq!(row_state(&s, 3..=3), [(false, false)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn changing_the_summary_direction_moves_collapsed_marks() {
|
|
||||||
let mut s = nested_rows();
|
|
||||||
s.execute("data.group", json!({"cols": "C:E"})).unwrap();
|
|
||||||
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
|
|
||||||
s.execute("data.hideDetail", json!({"cols": "C:E"})).unwrap();
|
|
||||||
s.execute("data.outlineSettings", json!({"summaryBelow": false, "summaryRight": false})).unwrap();
|
|
||||||
let col_marked = |s: &Session, c: u32| s.doc().unwrap().wb.active().unwrap().cols.get(&c).is_some_and(|l| l.collapsed);
|
|
||||||
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [true, false, false, false, false, false]);
|
|
||||||
assert!(col_marked(&s, 1) && !col_marked(&s, 5));
|
|
||||||
s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap();
|
|
||||||
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [false, false, false, false, false, true]);
|
|
||||||
assert!(col_marked(&s, 1), "columns keep their direction");
|
|
||||||
// One undo step per change; no params leaves the settings as they are.
|
|
||||||
s.execute("edit.undo", json!({})).unwrap();
|
|
||||||
assert!(row_state(&s, 3..=3)[0].1);
|
|
||||||
assert_eq!(s.execute("data.outlineSettings", json!({})).unwrap(), json!({"summaryBelow": false, "summaryRight": false}));
|
|
||||||
// Nested groups that end on one row share its mark: it moves to the outer group only.
|
|
||||||
let mut s = Session::new();
|
|
||||||
s.new_workbook();
|
|
||||||
s.execute("data.group", json!({"rows": "5:8"})).unwrap();
|
|
||||||
s.execute("data.group", json!({"rows": "6:8"})).unwrap();
|
|
||||||
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
|
|
||||||
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
|
|
||||||
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [true, false, false, false, false, false]);
|
|
||||||
s.execute("data.showDetail", json!({"rows": "4:4"})).unwrap();
|
|
||||||
assert_eq!(row_state(&s, 3..=8), [(false, false); 6]);
|
|
||||||
// With only the inner group collapsed, its mark goes to row 5, and the outer group's
|
|
||||||
// Hide and Show Detail keep it collapsed.
|
|
||||||
s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap();
|
|
||||||
s.execute("data.hideDetail", json!({"rows": "6:8", "level": 2})).unwrap();
|
|
||||||
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
|
|
||||||
assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]);
|
|
||||||
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
|
|
||||||
s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap();
|
|
||||||
assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]);
|
|
||||||
// A group on the first row has no row above it to mark.
|
|
||||||
let mut s = Session::new();
|
|
||||||
s.new_workbook();
|
|
||||||
s.execute("data.group", json!({"rows": "1:3"})).unwrap();
|
|
||||||
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
|
|
||||||
s.execute("data.hideDetail", json!({"rows": "1:3"})).unwrap();
|
|
||||||
assert_eq!(row_state(&s, 0..=3), [(true, false), (true, false), (true, false), (false, false)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn column_sort_rejects_row_key_zero_without_panicking() {
|
fn column_sort_rejects_row_key_zero_without_panicking() {
|
||||||
let mut s = Session::new();
|
let mut s = Session::new();
|
||||||
|
|||||||
+16
-274
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
cmd!(noundo "selection.currentRegion", "Select Current Region", [], Some("Ctrl+Shift+8"), "{}", has_doc, select_region),
|
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),
|
cmd!(noundo "selection.row", "Select Entire Row", [], Some("Shift+Space"), "{}", has_doc, select_row),
|
||||||
cmd!(noundo "selection.column", "Select Entire Column", [], Some("Ctrl+Space"), "{}", has_doc, select_col),
|
cmd!(noundo "selection.column", "Select Entire Column", [], Some("Ctrl+Space"), "{}", has_doc, select_col),
|
||||||
cmd!(noundo "edit.goTo", "Go To…", ["Home", "Editing", "Find & Select"], Some("Ctrl+G"), "{reference: \"B5\" | \"Sheet2!A1:C3\" | name | \"Sheet2!Name\" | \"B14:C22,F19:G30\" | \"Price, Type\", record?: bool} (record: keep a reference without names among the recent ones) or {list: true} → {names, recent}", has_doc, go_to),
|
cmd!(noundo "edit.goTo", "Go To…", ["Home", "Editing", "Find & Select"], Some("Ctrl+G"), "{reference: \"B5\" | \"Sheet2!A1:C3\" | name}", has_doc, go_to),
|
||||||
cmd!(noundo "edit.goToSpecial", "Go To Special…", ["Home", "Editing", "Find & Select"], None, "{kind: blanks|constants|formulas|comments|lastCell|currentRegion|visible|conditionalFormats|dataValidation|errors|numbers|text}", has_doc, go_to_special),
|
cmd!(noundo "edit.goToSpecial", "Go To Special…", ["Home", "Editing", "Find & Select"], None, "{kind: blanks|constants|formulas|comments|lastCell|currentRegion|visible|conditionalFormats|dataValidation|errors|numbers|text}", has_doc, go_to_special),
|
||||||
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{steps?: 1}", can_undo, undo),
|
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{steps?: 1}", can_undo, undo),
|
||||||
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{steps?: 1}", can_redo, redo),
|
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{steps?: 1}", can_redo, redo),
|
||||||
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
"AutoFill",
|
"AutoFill",
|
||||||
[],
|
[],
|
||||||
None,
|
None,
|
||||||
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend|days|weekdays|months|years}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up; days, weekdays, months, years = Fill Days, Fill Weekdays, Fill Months, Fill Years: dates step by one unit, or by the difference between the first two dates in that unit, and weekdays skip Saturday and Sunday)",
|
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up)",
|
||||||
has_doc,
|
has_doc,
|
||||||
auto_fill
|
auto_fill
|
||||||
),
|
),
|
||||||
@@ -577,154 +577,26 @@ fn select_col(s: &mut Session, _: &Json) -> Result<Json> {
|
|||||||
Ok(json!({"selection": d.selection.a1()}))
|
Ok(json!({"selection": d.selection.a1()}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many references the Go To dialog keeps for going back to.
|
|
||||||
const GO_TO_RECENT: usize = 10;
|
|
||||||
/// The most areas one Go To selects.
|
|
||||||
const GO_TO_MAX_AREAS: usize = 2000;
|
|
||||||
/// How deep a name that refers to other names is followed.
|
|
||||||
const GO_TO_NAME_DEPTH: usize = 8;
|
|
||||||
|
|
||||||
pub(super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
|
pub(super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
if bool_param(p, "list") == Some(true) {
|
|
||||||
let d = s.doc()?;
|
|
||||||
return Ok(json!({"names": go_to_names(&d.wb, d.wb.active_sheet), "recent": d.go_to_recent}));
|
|
||||||
}
|
|
||||||
let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) };
|
let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) };
|
||||||
|
// A defined name or table?
|
||||||
let d = s.doc()?;
|
let d = s.doc()?;
|
||||||
let mut target: Option<usize> = None;
|
let resolved = if RangeRef::parse(split_sheet(reference).1).is_none() {
|
||||||
let mut areas: Vec<RangeRef> = Vec::new();
|
let sheet = d.wb.active_sheet;
|
||||||
let mut named = false;
|
if let Some(n) = d.wb.name(reference, sheet) {
|
||||||
for part in reference_parts(reference) {
|
n.formula.clone()
|
||||||
let Some((sheet, found, by_name)) = go_to_target(&d.wb, d.wb.active_sheet, part, 0) else {
|
} else if let Some((si, ti)) = d.wb.table(reference) {
|
||||||
return Err(EngineError::Other("Reference isn't valid.".into()));
|
// As in Excel, a table's name stands for its data, without the header and totals rows.
|
||||||
};
|
let t = d.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.data_range().unwrap_or(t.range).a1()).unwrap_or_default();
|
||||||
// One selection holds the areas of one sheet.
|
format!("{}!{}", gridcraft_core::quote_sheet(&d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default()), t)
|
||||||
if target.is_some_and(|t| t != sheet) {
|
} else {
|
||||||
return Err(EngineError::Other("Reference isn't valid.".into()));
|
return Err(EngineError::Other("Reference isn't valid.".into()));
|
||||||
}
|
}
|
||||||
target = Some(sheet);
|
} else {
|
||||||
named |= by_name;
|
reference.to_string()
|
||||||
areas.extend(found);
|
|
||||||
if areas.len() > GO_TO_MAX_AREAS {
|
|
||||||
return Err(EngineError::Other(format!("Go To selects at most {GO_TO_MAX_AREAS} areas.")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let Some(sheet) = target else { return Err(EngineError::Other("Reference isn't valid.".into())) };
|
|
||||||
let range = areas.iter().map(RangeRef::a1).collect::<Vec<_>>().join(",");
|
|
||||||
let out = selection_set(s, &json!({"sheet": sheet, "range": range}))?;
|
|
||||||
if bool_param(p, "record") == Some(true) && !named {
|
|
||||||
let d = s.doc_mut()?;
|
|
||||||
let prefix = gridcraft_core::quote_sheet(&d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default());
|
|
||||||
let entry = areas.iter().map(|r| format!("{prefix}!{}", r.a1())).collect::<Vec<_>>().join(",");
|
|
||||||
d.go_to_recent.retain(|r| !r.eq_ignore_ascii_case(&entry));
|
|
||||||
d.go_to_recent.insert(0, entry);
|
|
||||||
d.go_to_recent.truncate(GO_TO_RECENT);
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The parts of a Go To reference, split at the commas outside quoted sheet names and
|
|
||||||
/// parentheses: `Price, Type` or `B14:C22,F19:G30`. The parts are read one at a time, so a caller
|
|
||||||
/// that stops at an invalid part or at `GO_TO_MAX_AREAS` does not split the rest of the text.
|
|
||||||
fn reference_parts(text: &str) -> impl Iterator<Item = &str> {
|
|
||||||
let (mut depth, mut quoted, mut start, mut done) = (0usize, false, 0, false);
|
|
||||||
let mut chars = text.char_indices();
|
|
||||||
std::iter::from_fn(move || {
|
|
||||||
if done {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
for (i, c) in chars.by_ref() {
|
|
||||||
match c {
|
|
||||||
'\'' => quoted = !quoted,
|
|
||||||
'(' if !quoted => depth += 1,
|
|
||||||
')' if !quoted => depth = depth.saturating_sub(1),
|
|
||||||
',' if !quoted && depth == 0 => {
|
|
||||||
let part = text.get(start..i).unwrap_or("").trim();
|
|
||||||
start = i + 1;
|
|
||||||
return Some(part);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
done = true;
|
|
||||||
Some(text.get(start..).unwrap_or("").trim())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, its areas,
|
|
||||||
/// and whether a defined name or a table gave them. `Sheet2!Name` is the name scoped to Sheet2.
|
|
||||||
/// A name counts when what it refers to is a reference; a constant or a formula is not one.
|
|
||||||
fn go_to_target(wb: &gridcraft_model::Workbook, sheet: usize, part: &str, depth: usize) -> Option<(usize, Vec<RangeRef>, bool)> {
|
|
||||||
if part.is_empty() || depth > GO_TO_NAME_DEPTH {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let (prefix, body) = split_sheet(part);
|
|
||||||
let on = match &prefix {
|
|
||||||
Some(n) => wb.sheet_index(n)?,
|
|
||||||
None => sheet,
|
|
||||||
};
|
};
|
||||||
if let Some(r) = RangeRef::parse(&body.replace('$', "")) {
|
let resolved = resolved.replace('$', "");
|
||||||
return Some((on, vec![r], false));
|
selection_set(s, &json!({"range": resolved}))
|
||||||
}
|
|
||||||
let name = match prefix {
|
|
||||||
Some(_) => wb.names.iter().find(|n| n.scope == Some(on) && gridcraft_formula::same_name(&n.name, body)),
|
|
||||||
None => wb.name(body, sheet),
|
|
||||||
};
|
|
||||||
if let Some(n) = name {
|
|
||||||
return name_areas(wb, n, sheet, depth).map(|(t, areas)| (t, areas, true));
|
|
||||||
}
|
|
||||||
if prefix.is_none()
|
|
||||||
&& let Some((si, ti)) = wb.table(body)
|
|
||||||
{
|
|
||||||
// As in Excel, a table's name stands for its data, without the header and totals rows.
|
|
||||||
let t = wb.sheet(si)?.tables.get(ti)?;
|
|
||||||
return Some((si, vec![t.data_range().unwrap_or(t.range)], true));
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The sheet and areas defined name `n` refers to, seen from sheet `sheet`, when that is a
|
|
||||||
/// reference.
|
|
||||||
fn name_areas(wb: &gridcraft_model::Workbook, n: &gridcraft_model::DefinedName, sheet: usize, depth: usize) -> Option<(usize, Vec<RangeRef>)> {
|
|
||||||
let from = n.scope.unwrap_or(sheet);
|
|
||||||
let formula = n.formula.trim().trim_start_matches('=');
|
|
||||||
let mut target: Option<usize> = None;
|
|
||||||
let mut areas = Vec::new();
|
|
||||||
for p in reference_parts(formula) {
|
|
||||||
let (s, found, _) = go_to_target(wb, from, p, depth + 1)?;
|
|
||||||
if target.is_some_and(|t| t != s) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
target = Some(s);
|
|
||||||
areas.extend(found);
|
|
||||||
if areas.len() > GO_TO_MAX_AREAS {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
target.map(|t| (t, areas))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The names the Go To dialog lists, seen from sheet `sheet`: the named ranges of the workbook and
|
|
||||||
/// of every sheet, in alphabetical order. A name scoped to another sheet is written `Sheet2!Name`,
|
|
||||||
/// and the active sheet's own name hides the workbook name it shares. Hidden names and names that
|
|
||||||
/// do not refer to a range are left out.
|
|
||||||
pub fn go_to_names(wb: &gridcraft_model::Workbook, sheet: usize) -> Vec<String> {
|
|
||||||
let own: std::collections::HashSet<String> =
|
|
||||||
wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| gridcraft_formula::fold_name(&n.name)).collect();
|
|
||||||
let mut out: Vec<String> = wb
|
|
||||||
.names
|
|
||||||
.iter()
|
|
||||||
.filter(|n| !n.hidden && name_areas(wb, n, sheet, 0).is_some())
|
|
||||||
.filter_map(|n| match n.scope {
|
|
||||||
None if own.contains(&gridcraft_formula::fold_name(&n.name)) => None,
|
|
||||||
None => Some(n.name.clone()),
|
|
||||||
Some(s) if s == sheet => Some(n.name.clone()),
|
|
||||||
Some(s) => Some(format!("{}!{}", gridcraft_core::quote_sheet(&wb.sheet(s)?.name), n.name)),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
out.sort_by_cached_key(|n| gridcraft_formula::fold_name(n));
|
|
||||||
out.dedup_by(|a, b| gridcraft_formula::same_name(a, b));
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn go_to_special(s: &mut Session, p: &Json) -> Result<Json> {
|
fn go_to_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
@@ -1590,10 +1462,6 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
|||||||
"formats" => crate::fill::FillMode::Formats,
|
"formats" => crate::fill::FillMode::Formats,
|
||||||
"values" => crate::fill::FillMode::ValuesOnly,
|
"values" => crate::fill::FillMode::ValuesOnly,
|
||||||
"extend" => crate::fill::FillMode::Extend,
|
"extend" => crate::fill::FillMode::Extend,
|
||||||
"days" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Day),
|
|
||||||
"weekdays" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Weekday),
|
|
||||||
"months" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Month),
|
|
||||||
"years" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Year),
|
|
||||||
_ => crate::fill::FillMode::Series,
|
_ => crate::fill::FillMode::Series,
|
||||||
};
|
};
|
||||||
if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS {
|
if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS {
|
||||||
@@ -1918,132 +1786,6 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result<Json> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Sheet1 and Sheet2 with a workbook range name, a name scoped to each sheet, a constant, a
|
|
||||||
/// formula and a hidden name.
|
|
||||||
fn go_to_workbook() -> Session {
|
|
||||||
let mut s = Session::new();
|
|
||||||
s.new_workbook();
|
|
||||||
s.execute("home.insertSheet", json!({"name": "Sheet2"})).unwrap();
|
|
||||||
s.execute("selection.set", json!({"sheet": 0})).unwrap();
|
|
||||||
let define = |s: &mut Session, name: &str, refers: &str, scope: &str| {
|
|
||||||
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap();
|
|
||||||
};
|
|
||||||
define(&mut s, "Price", "=Sheet1!$B$2:$B$5", "Workbook");
|
|
||||||
define(&mut s, "type", "=Sheet1!$D$2:$D$5", "Workbook");
|
|
||||||
define(&mut s, "Areas", "=Sheet1!$A$1,Sheet1!$C$3:$C$4", "Workbook");
|
|
||||||
define(&mut s, "Local", "=Sheet2!$C$3", "Sheet2");
|
|
||||||
define(&mut s, "Own", "=Sheet1!$E$1", "Sheet1");
|
|
||||||
define(&mut s, "Rate", "=0.07", "Workbook");
|
|
||||||
define(&mut s, "Total", "=SUM(Sheet1!$B$2:$B$5)", "Workbook");
|
|
||||||
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
|
|
||||||
wb.names.push(gridcraft_model::DefinedName {
|
|
||||||
name: "Secret".into(),
|
|
||||||
scope: None,
|
|
||||||
formula: "Sheet1!$Z$9".into(),
|
|
||||||
comment: String::new(),
|
|
||||||
hidden: true,
|
|
||||||
});
|
|
||||||
s
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "The Go to popup window shows named ranges on every worksheet in your workbook." Names
|
|
||||||
/// that are not ranges, and hidden names, are not listed.
|
|
||||||
#[test]
|
|
||||||
fn go_to_lists_the_named_ranges_of_every_sheet() {
|
|
||||||
let mut s = go_to_workbook();
|
|
||||||
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
|
|
||||||
assert_eq!(list["names"], json!(["Areas", "Own", "Price", "Sheet2!Local", "type"]), "{list}");
|
|
||||||
assert_eq!(list["recent"], json!([]), "{list}");
|
|
||||||
// Seen from Sheet2, its own name is bare and Sheet1's is qualified.
|
|
||||||
s.execute("selection.set", json!({"sheet": 1})).unwrap();
|
|
||||||
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
|
|
||||||
assert_eq!(list["names"], json!(["Areas", "Local", "Price", "Sheet1!Own", "type"]), "{list}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn go_to_a_name_scoped_to_another_sheet_goes_to_that_sheet() {
|
|
||||||
let mut s = go_to_workbook();
|
|
||||||
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!Local"})).unwrap();
|
|
||||||
assert_eq!(out["selection"], "C3", "{out}");
|
|
||||||
assert_eq!(s.doc().unwrap().wb.active_sheet, 1);
|
|
||||||
// A name of several areas selects all of them.
|
|
||||||
let out = s.execute("edit.goTo", json!({"reference": "Areas"})).unwrap();
|
|
||||||
assert_eq!(out["selection"], "A1,C3:C4", "{out}");
|
|
||||||
assert_eq!(s.doc().unwrap().wb.active_sheet, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A name matches in any case and any script, as a formula matches it.
|
|
||||||
#[test]
|
|
||||||
fn go_to_matches_a_sheet_name_in_any_case() {
|
|
||||||
let mut s = go_to_workbook();
|
|
||||||
s.execute("formulas.defineName", json!({"name": "Größe", "refersTo": "=Sheet2!$D$4", "scope": "Sheet2"})).unwrap();
|
|
||||||
s.execute("formulas.defineName", json!({"name": "GRÖSSE", "refersTo": "=Sheet1!$F$6", "scope": "Workbook"})).unwrap();
|
|
||||||
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!GRÖSSE"})).unwrap();
|
|
||||||
assert_eq!(out["selection"], "D4", "{out}");
|
|
||||||
// Seen from Sheet2, its own name hides the workbook name it shares.
|
|
||||||
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
|
|
||||||
assert_eq!(list["names"], json!(["Areas", "Größe", "Local", "Price", "Sheet1!Own", "type"]), "{list}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "You can enter multiple named ranges or cell references in the Reference box. Separating
|
|
||||||
/// each with a comma ... Excel will highlight all the ranges."
|
|
||||||
#[test]
|
|
||||||
fn go_to_selects_every_reference_and_name_in_a_list() {
|
|
||||||
let mut s = go_to_workbook();
|
|
||||||
let out = s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30,H21:H29"})).unwrap();
|
|
||||||
assert_eq!(out["selection"], "B14:C22,F19:G30,H21:H29", "{out}");
|
|
||||||
let out = s.execute("edit.goTo", json!({"reference": "Price, Type"})).unwrap();
|
|
||||||
assert_eq!(out["selection"], "B2:B5,D2:D5", "{out}");
|
|
||||||
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!$A$1,Sheet2!B2"})).unwrap();
|
|
||||||
assert_eq!(out["selection"], "A1,B2", "{out}");
|
|
||||||
assert_eq!(s.doc().unwrap().wb.active_sheet, 1);
|
|
||||||
// Areas on two sheets, and a part that is not a reference, leave the selection alone.
|
|
||||||
for bad in ["A1,Sheet1!B2", "A1,Rate", "A1,", "Total"] {
|
|
||||||
assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err(), "{bad}");
|
|
||||||
assert_eq!(s.doc().unwrap().selection.a1(), "A1,B2", "{bad}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn go_to_reads_the_parts_of_a_long_reference_one_at_a_time() {
|
|
||||||
let mut parts = reference_parts("A1, 'Q1, Q2'!B2,SUM(C3,D4)");
|
|
||||||
assert_eq!(parts.next(), Some("A1"));
|
|
||||||
assert_eq!(parts.next(), Some("'Q1, Q2'!B2"));
|
|
||||||
assert_eq!(parts.next(), Some("SUM(C3,D4)"));
|
|
||||||
assert_eq!(parts.next(), None);
|
|
||||||
assert_eq!(reference_parts("").collect::<Vec<_>>(), vec![""]);
|
|
||||||
let mut s = go_to_workbook();
|
|
||||||
let err = s.execute("edit.goTo", json!({"reference": "A1,".repeat(GO_TO_MAX_AREAS + 1)})).unwrap_err();
|
|
||||||
assert!(err.to_string().contains("at most"), "{err}");
|
|
||||||
let bad = format!("Total{}", ",".repeat(1_000_000));
|
|
||||||
assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "The Go to box keeps track of ranges as you enter them, and you can return to any of them."
|
|
||||||
#[test]
|
|
||||||
fn go_to_keeps_the_references_entered_newest_first() {
|
|
||||||
let mut s = go_to_workbook();
|
|
||||||
s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30", "record": true})).unwrap();
|
|
||||||
s.execute("edit.goTo", json!({"reference": "Sheet2!A5", "record": true})).unwrap();
|
|
||||||
// Names are listed already; a call without `record` (the Name Box) is not kept.
|
|
||||||
s.execute("edit.goTo", json!({"reference": "Price", "record": true})).unwrap();
|
|
||||||
s.execute("edit.goTo", json!({"reference": "Z9"})).unwrap();
|
|
||||||
s.execute("edit.goTo", json!({"reference": "b14:c22,f19:g30", "record": true})).unwrap();
|
|
||||||
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
|
|
||||||
assert_eq!(list["recent"], json!(["Sheet1!B14:C22,Sheet1!F19:G30", "Sheet2!A5"]), "{list}");
|
|
||||||
// Going back from Sheet2 to a recent reference returns to its sheet.
|
|
||||||
s.execute("selection.set", json!({"sheet": 1})).unwrap();
|
|
||||||
let out = s.execute("edit.goTo", json!({"reference": "Sheet1!B14:C22,Sheet1!F19:G30", "record": true})).unwrap();
|
|
||||||
assert_eq!(out["selection"], "B14:C22,F19:G30", "{out}");
|
|
||||||
assert_eq!(s.doc().unwrap().wb.active_sheet, 0);
|
|
||||||
for i in 0..GO_TO_RECENT + 2 {
|
|
||||||
s.execute("edit.goTo", json!({"reference": format!("A{}", i + 1), "record": true})).unwrap();
|
|
||||||
}
|
|
||||||
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
|
|
||||||
assert_eq!(list["recent"].as_array().map(Vec::len), Some(GO_TO_RECENT), "{list}");
|
|
||||||
assert_eq!(list["recent"][0], format!("Sheet1!A{}", GO_TO_RECENT + 2), "{list}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cell_set_takes_numbers_for_input_and_value() {
|
fn cell_set_takes_numbers_for_input_and_value() {
|
||||||
// #697: a JSON number for `input` used to clear the cell instead of entering it.
|
// #697: a JSON number for `input` used to clear the cell instead of entering it.
|
||||||
|
|||||||
@@ -941,7 +941,7 @@ fn from_csv(s: &mut Session, p: &Json) -> Result<Json> {
|
|||||||
return Err(EngineError::Other("Nothing to import.".into()));
|
return Err(EngineError::Other("Nothing to import.".into()));
|
||||||
}
|
}
|
||||||
let name = str_param(p, "sheet").map(str::to_string).or_else(|| {
|
let name = str_param(p, "sheet").map(str::to_string).or_else(|| {
|
||||||
str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(std::ffi::OsStr::to_str).map(|f| f.chars().take(31).collect()))
|
str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(|f| f.to_str()).map(|f| f.chars().take(31).collect()))
|
||||||
});
|
});
|
||||||
let r = s.execute("home.insertSheet", json!({"name": name})).or_else(|_| s.execute("home.insertSheet", json!({})))?;
|
let r = s.execute("home.insertSheet", json!({"name": name})).or_else(|_| s.execute("home.insertSheet", json!({})))?;
|
||||||
let w = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
|
let w = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
|
||||||
@@ -1034,11 +1034,6 @@ fn denied_script_step(id: &str, params: &Json) -> Option<String> {
|
|||||||
if id.starts_with("file.") || id.starts_with("link.") {
|
if id.starts_with("file.") || id.starts_with("link.") {
|
||||||
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
|
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
|
||||||
}
|
}
|
||||||
// Scripts are scoped to their workbook: a step that switches the active window would
|
|
||||||
// point every later step at another open document, so those are refused too.
|
|
||||||
if id.starts_with("window.") {
|
|
||||||
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
|
|
||||||
}
|
|
||||||
if params.get("path").is_some() {
|
if params.get("path").is_some() {
|
||||||
return Some(format!("scripts can't pass a file `path` to `{id}`"));
|
return Some(format!("scripts can't pass a file `path` to `{id}`"));
|
||||||
}
|
}
|
||||||
@@ -1270,7 +1265,7 @@ fn recently_used(s: &mut Session, _: &Json) -> Result<Json> {
|
|||||||
|
|
||||||
fn use_in_formula(s: &mut Session, p: &Json) -> Result<Json> {
|
fn use_in_formula(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
let name = str_param(p, "name").ok_or_else(|| bad("formulas.useInFormula", "missing `name`"))?;
|
let name = str_param(p, "name").ok_or_else(|| bad("formulas.useInFormula", "missing `name`"))?;
|
||||||
s.ui_requests.push(crate::UiRequest::InsertName(name.to_string()));
|
s.ui_requests.push(crate::UiRequest::EditCell(Some(format!("={name}"))));
|
||||||
ok()
|
ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
|||||||
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
|
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
|
||||||
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
|
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
|
||||||
let title = if imported {
|
let title = if imported {
|
||||||
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book"))
|
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book"))
|
||||||
} else {
|
} else {
|
||||||
std::path::Path::new(&name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Book").to_string()
|
std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string()
|
||||||
};
|
};
|
||||||
// An import has no save target: ordinary Save must ask for a new destination. Neither does a
|
// An import has no save target: ordinary Save must ask for a new destination. Neither does a
|
||||||
// workbook decrypted with its password, so Save never replaces the encrypted original with
|
// workbook decrypted with its password, so Save never replaces the encrypted original with
|
||||||
@@ -114,7 +114,7 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
|
|||||||
};
|
};
|
||||||
// A PDF is a print of the sheet (Page Setup applies), not a copy of the workbook: the
|
// A PDF is a print of the sheet (Page Setup applies), not a copy of the workbook: the
|
||||||
// document keeps its file and its unsaved changes.
|
// document keeps its file and its unsaved changes.
|
||||||
if std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
|
if std::path::Path::new(&path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
|
||||||
return super::print::export_pdf(s, &json!({"path": path}));
|
return super::print::export_pdf(s, &json!({"path": path}));
|
||||||
}
|
}
|
||||||
let d = s.doc_mut()?;
|
let d = s.doc_mut()?;
|
||||||
|
|||||||
@@ -233,23 +233,10 @@ fn with(p: &Json, key: &str, v: Json) -> Json {
|
|||||||
pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) -> Result<Json> {
|
pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) -> Result<Json> {
|
||||||
let sheet = target_sheet(s, p)?;
|
let sheet = target_sheet(s, p)?;
|
||||||
let ranges = target_ranges(s, p)?;
|
let ranges = target_ranges(s, p)?;
|
||||||
let guarded = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.is_some());
|
|
||||||
if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pr.format_cells)) {
|
if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pr.format_cells)) {
|
||||||
return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into()));
|
return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into()));
|
||||||
}
|
}
|
||||||
edit(s, |cx| {
|
edit(s, |cx| {
|
||||||
// Formatting on a protected sheet keeps the cell's permission flags: a format call
|
|
||||||
// that flips `locked` from the format permission would authorize content edits that
|
|
||||||
// the password should still gate, so those flags may not change (Excel keeps that
|
|
||||||
// checkbox disabled while the sheet is protected).
|
|
||||||
const FLAG_GUARD: &str = "You can't change cell protection on a protected sheet. Unprotect it first.";
|
|
||||||
macro_rules! guard_flags {
|
|
||||||
($old:expr, $new:expr) => {
|
|
||||||
if guarded && cx.wb.styles.get($old).protection != cx.wb.styles.get($new).protection {
|
|
||||||
return Err(EngineError::Other(FLAG_GUARD.into()));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
let mut cache: std::collections::HashMap<StyleId, StyleId> = std::collections::HashMap::new();
|
let mut cache: std::collections::HashMap<StyleId, StyleId> = std::collections::HashMap::new();
|
||||||
for r in &ranges {
|
for r in &ranges {
|
||||||
let full_rows = r.is_full_rows();
|
let full_rows = r.is_full_rows();
|
||||||
@@ -264,7 +251,6 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
|
|||||||
info.and_then(|i| i.style).unwrap_or_default()
|
info.and_then(|i| i.style).unwrap_or_default()
|
||||||
};
|
};
|
||||||
let new = *cache.entry(cur).or_insert_with(|| cx.wb.styles.derive(cur, &f));
|
let new = *cache.entry(cur).or_insert_with(|| cx.wb.styles.derive(cur, &f));
|
||||||
guard_flags!(cur, new);
|
|
||||||
let sh = cx.sheet_mut(sheet)?;
|
let sh = cx.sheet_mut(sheet)?;
|
||||||
let map = if full_rows && !full_cols { &mut sh.rows } else { &mut sh.cols };
|
let map = if full_rows && !full_cols { &mut sh.rows } else { &mut sh.cols };
|
||||||
map.entry(line).or_default().style = Some(new);
|
map.entry(line).or_default().style = Some(new);
|
||||||
@@ -274,7 +260,6 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
|
|||||||
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(*r).map(|(c, x)| (c, x.style)).collect()).unwrap_or_default();
|
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(*r).map(|(c, x)| (c, x.style)).collect()).unwrap_or_default();
|
||||||
for (c, st) in &cells {
|
for (c, st) in &cells {
|
||||||
let new = *cache.entry(*st).or_insert_with(|| cx.wb.styles.derive(*st, &f));
|
let new = *cache.entry(*st).or_insert_with(|| cx.wb.styles.derive(*st, &f));
|
||||||
guard_flags!(*st, new);
|
|
||||||
cx.sheet_mut(sheet)?.set_style(*c, new);
|
cx.sheet_mut(sheet)?.set_style(*c, new);
|
||||||
}
|
}
|
||||||
// Formatting touched these cells: re-fit their rows (Excel auto-fits).
|
// Formatting touched these cells: re-fit their rows (Excel auto-fits).
|
||||||
@@ -291,7 +276,6 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
|
|||||||
let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default();
|
let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default();
|
||||||
let new = *cache.entry(st).or_insert_with(|| cx.wb.styles.derive(st, &f));
|
let new = *cache.entry(st).or_insert_with(|| cx.wb.styles.derive(st, &f));
|
||||||
if new != st {
|
if new != st {
|
||||||
guard_flags!(st, new);
|
|
||||||
cx.sheet_mut(sheet)?.set_style(c, new);
|
cx.sheet_mut(sheet)?.set_style(c, new);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Formulas tab: AutoSum, Insert Function, names, auditing, calculation.
|
//! Formulas tab: AutoSum, Insert Function, names, auditing, calculation.
|
||||||
|
|
||||||
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
|
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
|
||||||
use gridcraft_model::{CalcMode, Cell, DefinedName, Formula, Workbook};
|
use gridcraft_model::{CalcMode, DefinedName, Formula, Workbook};
|
||||||
use serde_json::{Value as Json, json};
|
use serde_json::{Value as Json, json};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -55,15 +55,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
has_doc,
|
has_doc,
|
||||||
create_from_selection
|
create_from_selection
|
||||||
),
|
),
|
||||||
cmd!(
|
|
||||||
"formulas.pasteName",
|
|
||||||
"Paste Names…",
|
|
||||||
["Formulas", "Defined Names"],
|
|
||||||
Some("F3"),
|
|
||||||
"{name?: \"Sales\" (one of the names listed; OK: puts the name at the caret of the cell being edited, or starts `=Sales` in the active cell), list?: true (Paste List: writes each name and what it refers to in two columns, from `at`), at?: cell (default the active cell)} → {names: [{name, refersTo}]}: the names the Paste Name dialog lists (workbook names and the active sheet's own, hidden names left out, in alphabetical order). With neither nothing changes; the menu opens the dialog.",
|
|
||||||
has_doc,
|
|
||||||
paste_name
|
|
||||||
),
|
|
||||||
cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents),
|
cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents),
|
||||||
cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents),
|
cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents),
|
||||||
cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents),
|
cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents),
|
||||||
@@ -519,61 +510,6 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The names Paste Names lists, as (name, refers-to text with `=`): the workbook's names and the
|
|
||||||
/// active sheet's own, without hidden names, in alphabetical order. A sheet's name hides the
|
|
||||||
/// workbook name it shares.
|
|
||||||
pub fn pasteable_names(wb: &Workbook) -> Vec<(String, String)> {
|
|
||||||
let sheet = wb.active_sheet;
|
|
||||||
let shown = |local: bool| wb.names.iter().filter(move |n| !n.hidden && n.scope.is_some() == local && n.scope.is_none_or(|i| i == sheet));
|
|
||||||
let local: std::collections::HashSet<String> = shown(true).map(|n| gridcraft_formula::fold_name(&n.name)).collect();
|
|
||||||
let mut out: Vec<(String, String)> = shown(true).map(|n| (n.name.clone(), format!("={}", n.formula))).collect();
|
|
||||||
for n in shown(false) {
|
|
||||||
if !local.contains(&gridcraft_formula::fold_name(&n.name)) {
|
|
||||||
out.push((n.name.clone(), format!("={}", n.formula)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.sort_by_cached_key(|(n, _)| n.to_lowercase());
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn paste_name(s: &mut Session, p: &Json) -> Result<Json> {
|
|
||||||
let d = s.doc()?;
|
|
||||||
let names = pasteable_names(&d.wb);
|
|
||||||
let listed: Vec<Json> = names.iter().map(|(n, r)| json!({"name": n, "refersTo": r})).collect();
|
|
||||||
if !bool_param(p, "list").unwrap_or(false) {
|
|
||||||
if let Some(name) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
|
|
||||||
let Some((listed_name, _)) = names.iter().find(|(n, _)| gridcraft_formula::same_name(n, name)) else {
|
|
||||||
return Err(EngineError::Other(format!("There's no name \"{name}\" to paste.")));
|
|
||||||
};
|
|
||||||
s.ui_requests.push(crate::UiRequest::InsertName(listed_name.clone()));
|
|
||||||
}
|
|
||||||
return Ok(json!({"names": listed}));
|
|
||||||
}
|
|
||||||
if names.is_empty() {
|
|
||||||
return Err(EngineError::Other("This workbook has no names to paste.".into()));
|
|
||||||
}
|
|
||||||
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
|
|
||||||
let rows = u32::try_from(names.len()).unwrap_or(u32::MAX);
|
|
||||||
if at.row.saturating_add(rows) > gridcraft_core::MAX_ROWS || at.col.saturating_add(2) > gridcraft_core::MAX_COLS {
|
|
||||||
return Err(EngineError::Other("There isn't room on the sheet for the list of names.".into()));
|
|
||||||
}
|
|
||||||
let sheet = d.wb.active_sheet;
|
|
||||||
let range = RangeRef::new(at, CellRef::new(at.row + rows - 1, at.col + 1));
|
|
||||||
edit(s, |cx| {
|
|
||||||
for (row, (name, refers)) in (at.row..).zip(&names) {
|
|
||||||
for (col, text) in [(at.col, name), (at.col + 1, refers)] {
|
|
||||||
let c = CellRef::new(row, col);
|
|
||||||
let sh = cx.sheet_mut(sheet)?;
|
|
||||||
let style = sh.style_id(c);
|
|
||||||
// The second column holds the refers-to text as a text value, with no formula.
|
|
||||||
sh.set_cell(c, Cell { value: Value::text(text.as_str()), formula: None, style });
|
|
||||||
cx.touch(sheet, c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(json!({"names": listed, "range": range.a1()}))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn trace_precedents(s: &mut Session, p: &Json) -> Result<Json> {
|
fn trace_precedents(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
let d = s.doc()?;
|
let d = s.doc()?;
|
||||||
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
||||||
@@ -728,12 +664,8 @@ fn error_checking(s: &mut Session, _: &Json) -> Result<Json> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let circ: Vec<String> = d
|
let circ: Vec<String> =
|
||||||
.calc
|
d.calc.circular.iter().map(|(si, c)| format!("{}!{}", d.wb.sheet(*si).map(|s| s.name.clone()).unwrap_or_default(), c.a1())).collect();
|
||||||
.circular
|
|
||||||
.iter()
|
|
||||||
.map(|(si, c)| format!("{}!{}", d.wb.sheet(*si).map(|s| gridcraft_formula::quote_sheet(&s.name)).unwrap_or_default(), c.a1()))
|
|
||||||
.collect();
|
|
||||||
Ok(json!({"errors": out, "circular": circ}))
|
Ok(json!({"errors": out, "circular": circ}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -945,92 +877,4 @@ mod tests {
|
|||||||
// The union parses as one, so the preview is what the union gives in a cell, not #NAME?.
|
// The union parses as one, so the preview is what the union gives in a cell, not #NAME?.
|
||||||
assert_eq!(u["value"], json!({"error": "#VALUE!"}));
|
assert_eq!(u["value"], json!({"error": "#VALUE!"}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A workbook with `Sales` and `rate`, a hidden name, a name of Sheet1 that hides a workbook
|
|
||||||
/// `Tax`, and a name of another sheet.
|
|
||||||
fn named_workbook() -> Session {
|
|
||||||
let mut s = Session::new();
|
|
||||||
s.new_workbook();
|
|
||||||
s.execute("formulas.defineName", json!({"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"})).unwrap();
|
|
||||||
s.execute("formulas.defineName", json!({"name": "rate", "refersTo": "=0.07"})).unwrap();
|
|
||||||
s.execute("formulas.defineName", json!({"name": "Tax", "refersTo": "=0.2"})).unwrap();
|
|
||||||
let d = s.doc_mut().unwrap();
|
|
||||||
let wb = std::sync::Arc::make_mut(&mut d.wb);
|
|
||||||
let name = |name: &str, scope, formula: &str, hidden| DefinedName {
|
|
||||||
name: name.into(),
|
|
||||||
scope,
|
|
||||||
formula: formula.into(),
|
|
||||||
comment: String::new(),
|
|
||||||
hidden,
|
|
||||||
};
|
|
||||||
wb.names.push(name("_Filter", None, "Sheet1!$A$1:$B$9", true));
|
|
||||||
wb.names.push(name("TAX", Some(0), "0.25", false));
|
|
||||||
wb.names.push(name("Other", Some(1), "1", false));
|
|
||||||
s
|
|
||||||
}
|
|
||||||
|
|
||||||
fn text_at(s: &Session, cell: &str) -> Value {
|
|
||||||
s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn paste_names_lists_the_names_the_active_sheet_can_use() {
|
|
||||||
let mut s = named_workbook();
|
|
||||||
let r = s.execute("formulas.pasteName", json!({})).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
r["names"],
|
|
||||||
json!([
|
|
||||||
{"name": "rate", "refersTo": "=0.07"},
|
|
||||||
{"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"},
|
|
||||||
{"name": "TAX", "refersTo": "=0.25"},
|
|
||||||
])
|
|
||||||
);
|
|
||||||
assert_eq!(text_at(&s, "A1"), Value::Empty, "listing the names writes nothing");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn paste_names_and_use_in_formula_ask_the_ui_to_insert_the_name() {
|
|
||||||
let mut s = named_workbook();
|
|
||||||
s.take_ui_requests();
|
|
||||||
s.execute("formulas.pasteName", json!({"name": "Sales"})).unwrap();
|
|
||||||
s.execute("formulas.useInFormula", json!({"name": "rate"})).unwrap();
|
|
||||||
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::InsertName("Sales".into()), crate::UiRequest::InsertName("rate".into())]);
|
|
||||||
assert_eq!(text_at(&s, "A1"), Value::Empty, "the cell changes only when the edit is entered");
|
|
||||||
// A name Paste Name doesn't list is refused; one typed in another case is pasted as listed.
|
|
||||||
assert!(s.execute("formulas.pasteName", json!({"name": "NoSuchName"})).is_err());
|
|
||||||
s.execute("formulas.pasteName", json!({"name": "SALES"})).unwrap();
|
|
||||||
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::InsertName("Sales".into())]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn paste_list_writes_each_name_and_what_it_refers_to() {
|
|
||||||
let mut s = named_workbook();
|
|
||||||
s.execute("selection.set", json!({"range": "D2"})).unwrap();
|
|
||||||
let r = s.execute("formulas.pasteName", json!({"list": true})).unwrap();
|
|
||||||
assert_eq!(r["range"], json!("D2:E4"));
|
|
||||||
let rows = [("D2", "rate"), ("E2", "=0.07"), ("D3", "Sales"), ("E3", "=Sheet1!$A$1:$A$3"), ("D4", "TAX"), ("E4", "=0.25")];
|
|
||||||
for (cell, text) in rows {
|
|
||||||
assert_eq!(text_at(&s, cell), Value::text(text), "{cell}");
|
|
||||||
}
|
|
||||||
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("E3").unwrap()).is_some_and(|c| c.formula.is_none()), "refers-to is text");
|
|
||||||
s.execute("edit.undo", json!({})).unwrap();
|
|
||||||
for (cell, _) in rows {
|
|
||||||
assert_eq!(text_at(&s, cell), Value::Empty, "{cell} after Undo");
|
|
||||||
}
|
|
||||||
s.execute("formulas.pasteName", json!({"list": true, "at": "B7"})).unwrap();
|
|
||||||
assert_eq!(text_at(&s, "B7"), Value::text("rate"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn paste_list_needs_names_and_room() {
|
|
||||||
let mut s = named_workbook();
|
|
||||||
let last = format!("A{}", gridcraft_core::MAX_ROWS - 1);
|
|
||||||
assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last})).is_err(), "three rows don't fit below the second-to-last row");
|
|
||||||
let last_col = format!("{}1", gridcraft_core::col_to_letters(gridcraft_core::MAX_COLS - 1));
|
|
||||||
assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last_col})).is_err(), "two columns don't fit in the last column");
|
|
||||||
let mut empty = Session::new();
|
|
||||||
empty.new_workbook();
|
|
||||||
assert!(empty.execute("formulas.pasteName", json!({"list": true})).is_err());
|
|
||||||
assert_eq!(empty.execute("formulas.pasteName", json!({})).unwrap()["names"], json!([]));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
"Chart Properties",
|
"Chart Properties",
|
||||||
["Chart Design"],
|
["Chart Design"],
|
||||||
None,
|
None,
|
||||||
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
"{chart?: id, type?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
||||||
has_doc,
|
has_doc,
|
||||||
chart_set
|
chart_set
|
||||||
),
|
),
|
||||||
@@ -582,7 +582,6 @@ pub fn chart_kind(t: &str, sub: &str) -> ChartKind {
|
|||||||
("pie", _) => ChartKind::Pie,
|
("pie", _) => ChartKind::Pie,
|
||||||
("doughnut", _) => ChartKind::Doughnut,
|
("doughnut", _) => ChartKind::Doughnut,
|
||||||
("area", "stacked") => ChartKind::AreaStacked,
|
("area", "stacked") => ChartKind::AreaStacked,
|
||||||
("area", "stacked100") => ChartKind::AreaStacked100,
|
|
||||||
("area", _) => ChartKind::Area,
|
("area", _) => ChartKind::Area,
|
||||||
("scatter", "lines") => ChartKind::ScatterLines,
|
("scatter", "lines") => ChartKind::ScatterLines,
|
||||||
("scatter", _) => ChartKind::Scatter,
|
("scatter", _) => ChartKind::Scatter,
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
|||||||
test_commands().iter().find(|c| c.id == id).copied()
|
test_commands().iter().find(|c| c.id == id).copied()
|
||||||
}
|
}
|
||||||
|
|
||||||
static TEST_COMMANDS: std::sync::Mutex<Vec<&CommandSpec>> = std::sync::Mutex::new(Vec::new());
|
static TEST_COMMANDS: std::sync::Mutex<Vec<&'static CommandSpec>> = std::sync::Mutex::new(Vec::new());
|
||||||
|
|
||||||
/// Test-only: register an extra command so UI integration tests can prove a pending
|
/// Test-only: register an extra command so UI integration tests can prove a pending
|
||||||
/// keytip becomes live the moment its command lands (see crates/ui-egui/tests/keytips.rs).
|
/// keytip becomes live the moment its command lands (see crates/ui-egui/tests/keytips.rs).
|
||||||
|
|||||||
@@ -184,19 +184,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
"Show Details",
|
"Show Details",
|
||||||
[],
|
[],
|
||||||
None,
|
None,
|
||||||
"{cell?: \"B5\" (a value, subtotal or grand total cell of a PivotTable on the active sheet; default: the active cell)} puts the source records behind that value on a new sheet before the current one → {sheet, records}. An error when the PivotTable's Enable show details option is off (`pivot.options` `{enableShowDetails: true}` turns it on).",
|
"{cell?: \"B5\" (a value, subtotal or grand total cell of a PivotTable on the active sheet; default: the active cell)} puts the source records behind that value on a new sheet before the current one → {sheet, records}",
|
||||||
has_doc,
|
has_doc,
|
||||||
show_details
|
show_details
|
||||||
),
|
),
|
||||||
cmd!(
|
|
||||||
"pivot.options",
|
|
||||||
"PivotTable Options",
|
|
||||||
["PivotTable Analyze", "PivotTable"],
|
|
||||||
None,
|
|
||||||
"{pivot?, enableShowDetails?: bool (Data › Enable show details: off, Show Details and double-clicking a value cell list no records)} → {enableShowDetails}",
|
|
||||||
has_doc,
|
|
||||||
options
|
|
||||||
),
|
|
||||||
cmd!(
|
cmd!(
|
||||||
"pivot.delete",
|
"pivot.delete",
|
||||||
"Delete PivotTable",
|
"Delete PivotTable",
|
||||||
@@ -253,7 +244,7 @@ fn find_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn area_param(cmd: &str, v: Option<&str>) -> Result<&'static str> {
|
fn area_param(cmd: &str, v: Option<&str>) -> Result<&'static str> {
|
||||||
match v.map(str::to_ascii_lowercase) {
|
match v.map(|x| x.to_ascii_lowercase()) {
|
||||||
Some(a) if a == "rows" || a == "row" => Ok("rows"),
|
Some(a) if a == "rows" || a == "row" => Ok("rows"),
|
||||||
Some(a) if a == "columns" || a == "column" || a == "cols" => Ok("columns"),
|
Some(a) if a == "columns" || a == "column" || a == "cols" => Ok("columns"),
|
||||||
Some(a) if a == "values" || a == "value" || a == "data" => Ok("values"),
|
Some(a) if a == "values" || a == "value" || a == "data" => Ok("values"),
|
||||||
@@ -764,13 +755,8 @@ fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
|
|||||||
.find(|pt| pt.last_range.is_some_and(|r| r.contains(at)))
|
.find(|pt| pt.last_range.is_some_and(|r| r.contains(at)))
|
||||||
.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
|
.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
|
||||||
let origin = pt.last_range.map(|r| r.start).unwrap_or_default();
|
let origin = pt.last_range.map(|r| r.start).unwrap_or_default();
|
||||||
let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col));
|
let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)).map_err(other)?;
|
||||||
// With the option off, a report that is out of date or a source that cannot be read is
|
let (src, records) = found.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
|
||||||
// reported as the option; only a cell that is not a value cell keeps its own message.
|
|
||||||
if !pt.enable_show_details && !matches!(found, Ok(None)) {
|
|
||||||
return Err(other(SHOW_DETAILS_OFF));
|
|
||||||
}
|
|
||||||
let (src, records) = found.map_err(other)?.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
|
|
||||||
if d.wb.protected_structure {
|
if d.wb.protected_structure {
|
||||||
return Err(other("The workbook is protected and cannot be changed."));
|
return Err(other("The workbook is protected and cannot be changed."));
|
||||||
}
|
}
|
||||||
@@ -838,32 +824,6 @@ fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
|
|||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The message Show Details gives while Enable show details is off.
|
|
||||||
const SHOW_DETAILS_OFF: &str = "Show Details is turned off for this PivotTable.";
|
|
||||||
|
|
||||||
/// PivotTable Options: Enable show details (Data). Changes only the option; the report is not
|
|
||||||
/// refreshed. Without params it returns the current setting.
|
|
||||||
fn options(s: &mut Session, p: &Json) -> Result<Json> {
|
|
||||||
let (si, pi) = find_pivot(s, p)?;
|
|
||||||
let enable = bool_param(p, "enableShowDetails");
|
|
||||||
if p.get("enableShowDetails").is_some_and(|v| !v.is_null()) && enable.is_none() {
|
|
||||||
return Err(bad("pivot.options", "`enableShowDetails` must be true or false"));
|
|
||||||
}
|
|
||||||
let current = |s: &Session| -> Result<Json> {
|
|
||||||
let pt = s.doc()?.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?;
|
|
||||||
Ok(json!({"enableShowDetails": pt.enable_show_details}))
|
|
||||||
};
|
|
||||||
let Some(on) = enable else { return current(s) };
|
|
||||||
if current(s)?["enableShowDetails"] == json!(on) {
|
|
||||||
return current(s);
|
|
||||||
}
|
|
||||||
edit(s, |cx| {
|
|
||||||
let pt = cx.sheet_mut(si)?.pivots.get_mut(pi).ok_or_else(|| other("There's no such PivotTable."))?;
|
|
||||||
pt.enable_show_details = on;
|
|
||||||
Ok(json!({"enableShowDetails": on}))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn summarize_table(s: &mut Session, p: &Json) -> Result<Json> {
|
fn summarize_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
let d = s.doc()?;
|
let d = s.doc()?;
|
||||||
let name = match str_param(p, "table") {
|
let name = match str_param(p, "table") {
|
||||||
@@ -901,7 +861,6 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json {
|
|||||||
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
|
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
|
||||||
"valuesOnRows": pt.values_on_rows,
|
"valuesOnRows": pt.values_on_rows,
|
||||||
"valuesPosition": pt.values_position,
|
"valuesPosition": pt.values_position,
|
||||||
"enableShowDetails": pt.enable_show_details,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2376,7 +2335,6 @@ mod tests {
|
|||||||
"pivot.changeSource",
|
"pivot.changeSource",
|
||||||
"pivot.delete",
|
"pivot.delete",
|
||||||
"pivot.showDetails",
|
"pivot.showDetails",
|
||||||
"pivot.options",
|
|
||||||
];
|
];
|
||||||
let hostile = [
|
let hostile = [
|
||||||
json!({}),
|
json!({}),
|
||||||
@@ -2622,72 +2580,4 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
|
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enable_show_details_off_stops_show_details() {
|
|
||||||
let mut s = sales();
|
|
||||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
|
|
||||||
assert_eq!(s.execute("pivot.options", json!({})).unwrap()["enableShowDetails"], true);
|
|
||||||
s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap();
|
|
||||||
assert!(!pt(&s).enable_show_details);
|
|
||||||
assert_eq!(s.execute("pivot.fields", json!({})).unwrap()["enableShowDetails"], false);
|
|
||||||
let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string();
|
|
||||||
assert_eq!(e, "Show Details is turned off for this PivotTable.");
|
|
||||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
|
|
||||||
// A label still reports that it is not a value cell.
|
|
||||||
let e = s.execute("pivot.showDetails", json!({"cell": "A5"})).unwrap_err().to_string();
|
|
||||||
assert!(!e.contains("turned off"), "{e}");
|
|
||||||
// Setting the same value again adds no undo step.
|
|
||||||
let after = s.doc().unwrap().revision;
|
|
||||||
s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap();
|
|
||||||
assert_eq!(s.doc().unwrap().revision, after);
|
|
||||||
assert!(s.execute("pivot.options", json!({"enableShowDetails": "no"})).is_err());
|
|
||||||
// One undo turns the option back on.
|
|
||||||
s.execute("edit.undo", json!({})).unwrap();
|
|
||||||
assert!(pt(&s).enable_show_details);
|
|
||||||
assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap()["records"], 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enable_show_details_off_is_reported_before_an_out_of_date_report() {
|
|
||||||
let mut s = sales();
|
|
||||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
|
|
||||||
s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap();
|
|
||||||
// The source changes after the report was written, so the report is out of date.
|
|
||||||
s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap();
|
|
||||||
let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string();
|
|
||||||
assert!(e.contains("turned off") && !e.contains("out of date"), "{e}");
|
|
||||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn detail_cells_are_the_cells_show_details_takes() {
|
|
||||||
let mut s = sales();
|
|
||||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
|
|
||||||
for layout in 0..2 {
|
|
||||||
if layout == 1 {
|
|
||||||
// A two-level row axis, with subtotals at the top of each group.
|
|
||||||
s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap();
|
|
||||||
}
|
|
||||||
let d = s.doc().unwrap();
|
|
||||||
let si = d.wb.active_sheet;
|
|
||||||
let pt = &d.wb.active().unwrap().pivots[0];
|
|
||||||
let r = pt.last_range.unwrap();
|
|
||||||
let cells = crate::pivot::detail_cells(&d.wb, pt, si).unwrap();
|
|
||||||
let mut values = 0;
|
|
||||||
for row in 0..r.height() {
|
|
||||||
for col in 0..r.width() {
|
|
||||||
let one = crate::pivot::detail_records(&d.wb, pt, si, row, col).unwrap();
|
|
||||||
assert_eq!(cells.contains(&(row, col)), one.is_some(), "layout {layout}, cell ({row}, {col})");
|
|
||||||
values += usize::from(one.is_some());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(values > 4, "layout {layout} has value cells");
|
|
||||||
}
|
|
||||||
// An out-of-date report answers as Show Details does.
|
|
||||||
s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap();
|
|
||||||
let d = s.doc().unwrap();
|
|
||||||
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"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,26 +139,8 @@ impl SheetLayout {
|
|||||||
let [l, r, t, b, ..] = self.margins;
|
let [l, r, t, b, ..] = self.margins;
|
||||||
((self.page_w - l - r).max(36.0), (self.page_h - t - b).max(36.0))
|
((self.page_w - l - r).max(36.0), (self.page_h - t - b).max(36.0))
|
||||||
}
|
}
|
||||||
/// Left and right margins, in points, that header and footer sections line up with: the
|
|
||||||
/// page's side margins with Align with Page Margins on, otherwise 0.7 inch on each side, at
|
|
||||||
/// most a quarter of the page width.
|
|
||||||
pub(crate) fn hf_sides(&self) -> (f32, f32) {
|
|
||||||
if self.settings.hf_align_with_margins {
|
|
||||||
(self.margins[0], self.margins[1])
|
|
||||||
} else {
|
|
||||||
let side = HF_FIXED_SIDE_MARGIN.min(self.page_w / 4.0);
|
|
||||||
(side, side)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// Scale of header and footer text: the print scale with Scale with Document on, otherwise 1.
|
|
||||||
pub(crate) fn hf_text_scale(&self) -> f32 {
|
|
||||||
if self.settings.hf_scale_with_doc { self.scale } else { 1.0 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Side margin of headers and footers, in points, when Align with Page Margins is off.
|
|
||||||
const HF_FIXED_SIDE_MARGIN: f32 = 0.7 * 72.0;
|
|
||||||
|
|
||||||
/// Options shared by the print commands.
|
/// Options shared by the print commands.
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub struct PrintOptions {
|
pub struct PrintOptions {
|
||||||
@@ -617,7 +599,7 @@ pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] {
|
|||||||
i += rest.chars().count() + 1;
|
i += rest.chars().count() + 1;
|
||||||
}
|
}
|
||||||
d if d.is_ascii_digit() => {
|
d if d.is_ascii_digit() => {
|
||||||
let digits: String = std::iter::once(d).chain(chars[i..].iter().copied().take_while(char::is_ascii_digit)).take(3).collect();
|
let digits: String = std::iter::once(d).chain(chars[i..].iter().copied().take_while(|c| c.is_ascii_digit())).take(3).collect();
|
||||||
i += digits.len() - 1;
|
i += digits.len() - 1;
|
||||||
if let Ok(v) = digits.parse::<f32>() {
|
if let Ok(v) = digits.parse::<f32>() {
|
||||||
sec.size = Some(v.clamp(1.0, 409.0));
|
sec.size = Some(v.clamp(1.0, 409.0));
|
||||||
@@ -1494,9 +1476,7 @@ fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at:
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model::HfPage, cx: &HfContext) {
|
fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model::HfPage, cx: &HfContext) {
|
||||||
let [_, _, _, _, hm, fm] = lay.margins;
|
let [l, r, _, _, hm, fm] = lay.margins;
|
||||||
let (l, r) = lay.hf_sides();
|
|
||||||
let text_scale = lay.hf_text_scale();
|
|
||||||
let pw = lay.page_w;
|
let pw = lay.page_w;
|
||||||
let ph = lay.page_h;
|
let ph = lay.page_h;
|
||||||
for (spec, header) in [(lay.settings.hf(which, false), true), (lay.settings.hf(which, true), false)] {
|
for (spec, header) in [(lay.settings.hf(which, false), true), (lay.settings.hf(which, true), false)] {
|
||||||
@@ -1508,7 +1488,7 @@ fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model::
|
|||||||
if sec.text.trim().is_empty() {
|
if sec.text.trim().is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let size = sec.size.unwrap_or(10.0).clamp(1.0, 72.0) * text_scale;
|
let size = sec.size.unwrap_or(10.0).clamp(1.0, 72.0);
|
||||||
let font = Font::styled(gridcraft_pdf::Family::Sans, sec.bold, sec.italic);
|
let font = Font::styled(gridcraft_pdf::Family::Sans, sec.bold, sec.italic);
|
||||||
let lines: Vec<&str> = sec.text.split('\n').take(20).collect();
|
let lines: Vec<&str> = sec.text.split('\n').take(20).collect();
|
||||||
let n = lines.len() as f32;
|
let n = lines.len() as f32;
|
||||||
@@ -2041,40 +2021,6 @@ mod tests {
|
|||||||
assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj"));
|
assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The x position and font size of the first `(text) Tj` in a PDF.
|
|
||||||
fn text_at(pdf: &[u8], text: &str) -> Option<(f32, f32)> {
|
|
||||||
let s = String::from_utf8_lossy(pdf);
|
|
||||||
let before = &s[..s.find(&format!("({text}) Tj"))?];
|
|
||||||
let tf = before.rfind(" Tf")?;
|
|
||||||
let size = before[..tf].rsplit(' ').next()?.parse().ok()?;
|
|
||||||
let tm = before.rfind(" Tm")?;
|
|
||||||
let mut nums = before[..tm].rsplit([' ', '\n']);
|
|
||||||
let _y = nums.next()?;
|
|
||||||
let x = nums.next()?.parse().ok()?;
|
|
||||||
Some((x, size))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn headers_scale_with_the_sheet_and_align_with_its_margins_unless_turned_off() {
|
|
||||||
let mut s = s();
|
|
||||||
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
|
|
||||||
s.execute("pageLayout.pageSetup", json!({"scale": 50, "left": 1.5, "right": 1.5})).unwrap();
|
|
||||||
s.execute("pageLayout.headerFooter", json!({"header": "&LLeft&C&14Middle"})).unwrap();
|
|
||||||
// Both on (the default): the text prints at half size, from the 1.5-inch left margin.
|
|
||||||
let (pdf, _) = pdf_of(&mut s, json!({}));
|
|
||||||
assert_eq!(text_at(&pdf, "Left"), Some((108.0, 5.0)));
|
|
||||||
assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(7.0));
|
|
||||||
let r = s.execute("pageLayout.headerFooter", json!({"scaleWithDoc": false, "alignWithMargins": false})).unwrap();
|
|
||||||
assert_eq!((&r["scaleWithDoc"], &r["alignWithMargins"]), (&json!(false), &json!(false)));
|
|
||||||
// Both off: full size, 0.7 inch from the paper's left edge.
|
|
||||||
let (pdf, _) = pdf_of(&mut s, json!({}));
|
|
||||||
assert_eq!(text_at(&pdf, "Left"), Some((50.4, 10.0)));
|
|
||||||
assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(14.0));
|
|
||||||
s.execute("edit.undo", json!({})).unwrap();
|
|
||||||
let pr = &s.doc().unwrap().wb.sheet(0).unwrap().print;
|
|
||||||
assert!(pr.hf_scale_with_doc && pr.hf_align_with_margins);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pdf_prints_cjk_text_instead_of_question_marks() {
|
fn pdf_prints_cjk_text_instead_of_question_marks() {
|
||||||
let mut s = s();
|
let mut s = s();
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ use gridcraft_formula::adjust::{Axis, Edit};
|
|||||||
use gridcraft_model::{Anchor, AnchorMode, Sheet, Visibility};
|
use gridcraft_model::{Anchor, AnchorMode, Sheet, Visibility};
|
||||||
use serde_json::{Value as Json, json};
|
use serde_json::{Value as Json, json};
|
||||||
|
|
||||||
use super::{
|
use super::*;
|
||||||
CommandSpec, Ctx, EngineError, PROTECTED, Result, Session, bad, bool_param, cell_param_on, cmd, edit, has_clipboard, has_doc, ok, parse_lines,
|
|
||||||
str_param, target_range, target_sheet, u32_param,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn specs() -> Vec<CommandSpec> {
|
pub fn specs() -> Vec<CommandSpec> {
|
||||||
vec![
|
vec![
|
||||||
@@ -273,29 +270,6 @@ fn cancel_copy_mode(s: &mut Session) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inherit_line_format(sh: &mut Sheet, axis: Axis, at: u32, count: u32) {
|
|
||||||
if at == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let prev = if axis == Axis::Rows { sh.rows.get(&(at - 1)).copied() } else { sh.cols.get(&(at - 1)).copied() };
|
|
||||||
if let Some(info) = prev {
|
|
||||||
for i in at..at + count {
|
|
||||||
let map = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
|
|
||||||
map.insert(i, gridcraft_model::LineInfo { hidden: false, outline: 0, collapsed: false, ..info });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_push_off_sheet(s: &Session, sheet: usize, axis: Axis, at: u32, count: u32, limit: u32) -> Result<()> {
|
|
||||||
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
|
|
||||||
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
|
|
||||||
if last >= at && last as u64 + count as u64 >= limit as u64 {
|
|
||||||
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||||
let (at, count) = lines(s, p, axis)?;
|
let (at, count) = lines(s, p, axis)?;
|
||||||
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
|
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
|
||||||
@@ -305,7 +279,13 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
|||||||
}
|
}
|
||||||
check_lines_protection(s, p, axis, at, count, true)?;
|
check_lines_protection(s, p, axis, at, count, true)?;
|
||||||
let sheet = target_sheet(s, p)?;
|
let sheet = target_sheet(s, p)?;
|
||||||
check_push_off_sheet(s, sheet, axis, at, count, limit)?;
|
// Refuse to push data off the sheet.
|
||||||
|
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
|
||||||
|
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
|
||||||
|
if last >= at && last as u64 + count as u64 >= limit as u64 {
|
||||||
|
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
let out = edit(s, |cx| {
|
let out = edit(s, |cx| {
|
||||||
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||||
cx.protection_checked = true;
|
cx.protection_checked = true;
|
||||||
@@ -317,7 +297,15 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
|||||||
}
|
}
|
||||||
shift_sheet_features(sh, axis, at, count, true);
|
shift_sheet_features(sh, axis, at, count, true);
|
||||||
// New lines take the format of the line before (Excel's "Format Same As Above").
|
// New lines take the format of the line before (Excel's "Format Same As Above").
|
||||||
inherit_line_format(sh, axis, at, count);
|
if at > 0 {
|
||||||
|
let prev = if axis == Axis::Rows { sh.rows.get(&(at - 1)).copied() } else { sh.cols.get(&(at - 1)).copied() };
|
||||||
|
if let Some(info) = prev {
|
||||||
|
for i in at..at + count {
|
||||||
|
let map = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
|
||||||
|
map.insert(i, gridcraft_model::LineInfo { hidden: false, outline: 0, collapsed: false, ..info });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
rewrite(&mut cx.wb, &name, &Edit::Insert { axis, at, count });
|
rewrite(&mut cx.wb, &name, &Edit::Insert { axis, at, count });
|
||||||
cx.structural = true;
|
cx.structural = true;
|
||||||
@@ -413,10 +401,8 @@ 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()));
|
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
|
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1()}), Axis::Cols, before, count, true)?;
|
||||||
// sheet is active: both carry the command's own `sheet` parameter on.
|
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1()}), Axis::Cols, from, count, false)?;
|
||||||
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)?;
|
|
||||||
|
|
||||||
let moved_from = if before <= from { from + count } else { from };
|
let moved_from = if before <= from { from + count } else { from };
|
||||||
if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) {
|
if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) {
|
||||||
|
|||||||
@@ -28,10 +28,8 @@ use gridcraft_core::{CellRef, RangeRef, Value};
|
|||||||
use gridcraft_model::{Cell, DefinedName, Sheet, Workbook};
|
use gridcraft_model::{Cell, DefinedName, Sheet, Workbook};
|
||||||
use serde_json::{Value as Json, json};
|
use serde_json::{Value as Json, json};
|
||||||
|
|
||||||
use super::check_arrays;
|
|
||||||
use super::whatif::Trial;
|
use super::whatif::Trial;
|
||||||
use super::{CommandSpec, Ctx, DocState, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, str_param};
|
use super::*;
|
||||||
use gridcraft_core::split_sheet;
|
|
||||||
|
|
||||||
const CMD: &str = "data.solver";
|
const CMD: &str = "data.solver";
|
||||||
const REPORT_CMD: &str = "data.solverReport";
|
const REPORT_CMD: &str = "data.solverReport";
|
||||||
@@ -1707,26 +1705,6 @@ fn title(out: &mut Lines, kind: ReportKind, sheet: &str) -> u32 {
|
|||||||
3
|
3
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_constraint_row(sh: &Sheet, c: &Constraint, cell: CellRef, v: f64, rhs: &[f64], k: usize) -> Vec<Value> {
|
|
||||||
let (formula, status, slack) = match (c.rel, &c.rhs) {
|
|
||||||
(Rel::Int, _) => (format!("{}=Integer", abs_ref(cell)), "Binding", 0.0),
|
|
||||||
(Rel::Bin, _) => (format!("{}=Binary", abs_ref(cell)), "Binding", 0.0),
|
|
||||||
(rel, rhs_cells) => {
|
|
||||||
let b = rhs.get(k).or_else(|| rhs.first()).copied().unwrap_or(f64::NAN);
|
|
||||||
let rhs_text = match rhs_cells {
|
|
||||||
Rhs::Cells(cells) => cells.get(k).or_else(|| cells.first()).map(|c| abs_ref(*c)).unwrap_or_default(),
|
|
||||||
_ => gridcraft_core::number_to_text(b),
|
|
||||||
};
|
|
||||||
let slack = (v - b).abs();
|
|
||||||
let binding = rel == Rel::Eq || slack <= PRECISION * (1.0 + b.abs());
|
|
||||||
let slack = if binding { 0.0 } else { slack };
|
|
||||||
(format!("{}{}{rhs_text}", abs_ref(cell), rel.text()), if binding { "Binding" } else { "Not Binding" }, slack)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let num = |v: f64| if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) };
|
|
||||||
vec![Value::text(abs_ref(cell)), Value::text(cell_name(sh, cell)), num(v), Value::text(formula), Value::text(status), num(slack)]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines {
|
fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines {
|
||||||
let m = inp.model;
|
let m = inp.model;
|
||||||
let mut trial = Trial::new(d);
|
let mut trial = Trial::new(d);
|
||||||
@@ -1790,7 +1768,29 @@ fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines {
|
|||||||
Rhs::None => vec![],
|
Rhs::None => vec![],
|
||||||
};
|
};
|
||||||
for (k, (cell, v)) in c.lhs.iter().zip(&lhs).enumerate() {
|
for (k, (cell, v)) in c.lhs.iter().zip(&lhs).enumerate() {
|
||||||
rows.push(format_constraint_row(sh, c, *cell, *v, &rhs, k));
|
let (formula, status, slack) = match (c.rel, &c.rhs) {
|
||||||
|
(Rel::Int, _) => (format!("{}=Integer", abs_ref(*cell)), "Binding", 0.0),
|
||||||
|
(Rel::Bin, _) => (format!("{}=Binary", abs_ref(*cell)), "Binding", 0.0),
|
||||||
|
(rel, rhs_cells) => {
|
||||||
|
let b = rhs.get(k).or_else(|| rhs.first()).copied().unwrap_or(f64::NAN);
|
||||||
|
let rhs_text = match rhs_cells {
|
||||||
|
Rhs::Cells(cells) => cells.get(k).or_else(|| cells.first()).map(|c| abs_ref(*c)).unwrap_or_default(),
|
||||||
|
_ => gridcraft_core::number_to_text(b),
|
||||||
|
};
|
||||||
|
let slack = (v - b).abs();
|
||||||
|
let binding = rel == Rel::Eq || slack <= PRECISION * (1.0 + b.abs());
|
||||||
|
let slack = if binding { 0.0 } else { slack };
|
||||||
|
(format!("{}{}{rhs_text}", abs_ref(*cell), rel.text()), if binding { "Binding" } else { "Not Binding" }, slack)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rows.push(vec![
|
||||||
|
Value::text(abs_ref(*cell)),
|
||||||
|
Value::text(cell_name(sh, *cell)),
|
||||||
|
num(*v),
|
||||||
|
Value::text(formula),
|
||||||
|
Value::text(status),
|
||||||
|
num(slack),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
table(&mut out, r + 1, &["Cell", "Name", "Cell Value", "Formula", "Status", "Slack"], rows);
|
table(&mut out, r + 1, &["Cell", "Name", "Cell Value", "Formula", "Status", "Slack"], rows);
|
||||||
|
|||||||
@@ -44,15 +44,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
s.prefs.formula_bar = bool_param(p, "on").unwrap_or(!s.prefs.formula_bar);
|
s.prefs.formula_bar = bool_param(p, "on").unwrap_or(!s.prefs.formula_bar);
|
||||||
Ok(json!({"on": s.prefs.formula_bar}))
|
Ok(json!({"on": s.prefs.formula_bar}))
|
||||||
}),
|
}),
|
||||||
cmd!(
|
|
||||||
query "view.statusBar",
|
|
||||||
"Customize Status Bar",
|
|
||||||
[],
|
|
||||||
None,
|
|
||||||
"{average?, count?, numericalCount?, minimum?, maximum?, sum?: bool (the statistics shown for a selection; returns all six)}",
|
|
||||||
always,
|
|
||||||
status_bar
|
|
||||||
),
|
|
||||||
cmd!("view.zoomToSelection", "Zoom to Selection", ["View", "Zoom"], None, "{viewWidth?, viewHeight?}", has_doc, zoom_selection),
|
cmd!("view.zoomToSelection", "Zoom to Selection", ["View", "Zoom"], None, "{viewWidth?, viewHeight?}", has_doc, zoom_selection),
|
||||||
cmd!("pageLayout.orientation", "Orientation", ["Page Layout", "Page Setup"], None, "{orientation: portrait|landscape}", has_doc, orientation),
|
cmd!("pageLayout.orientation", "Orientation", ["Page Layout", "Page Setup"], None, "{orientation: portrait|landscape}", has_doc, orientation),
|
||||||
cmd!(
|
cmd!(
|
||||||
@@ -124,7 +115,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
"Header & Footer",
|
"Header & Footer",
|
||||||
["Insert", "Text"],
|
["Insert", "Text"],
|
||||||
None,
|
None,
|
||||||
"{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\", differentFirst?: bool, differentOddEven?: bool, scaleWithDoc?: bool, alignWithMargins?: bool, firstHeader?, firstFooter?, evenHeader?, evenFooter?} or one section: {sheet?, page?: odd|even|first, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer, differentFirst, differentOddEven, scaleWithDoc, alignWithMargins, firstHeader, firstFooter, evenHeader, evenFooter}",
|
"{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\", differentFirst?: bool, differentOddEven?: bool, firstHeader?, firstFooter?, evenHeader?, evenFooter?} or one section: {sheet?, page?: odd|even|first, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer, differentFirst, differentOddEven, firstHeader, firstFooter, evenHeader, evenFooter}",
|
||||||
has_doc,
|
has_doc,
|
||||||
header_footer
|
header_footer
|
||||||
),
|
),
|
||||||
@@ -196,30 +187,6 @@ fn show_objects(s: &mut Session, p: &Json) -> Result<Json> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn status_bar(s: &mut Session, p: &Json) -> Result<Json> {
|
|
||||||
let st = &mut s.prefs.status_bar;
|
|
||||||
for (key, flag) in [
|
|
||||||
("average", &mut st.average),
|
|
||||||
("count", &mut st.count),
|
|
||||||
("numericalCount", &mut st.numerical_count),
|
|
||||||
("minimum", &mut st.minimum),
|
|
||||||
("maximum", &mut st.maximum),
|
|
||||||
("sum", &mut st.sum),
|
|
||||||
] {
|
|
||||||
if let Some(on) = bool_param(p, key) {
|
|
||||||
*flag = on;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(json!({
|
|
||||||
"average": st.average,
|
|
||||||
"count": st.count,
|
|
||||||
"numericalCount": st.numerical_count,
|
|
||||||
"minimum": st.minimum,
|
|
||||||
"maximum": st.maximum,
|
|
||||||
"sum": st.sum,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn zoom(s: &mut Session, p: &Json) -> Result<Json> {
|
fn zoom(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
let z = f64_param(p, "percent").ok_or_else(|| bad("view.zoom", "missing `percent`"))?.clamp(10.0, 400.0) as u16;
|
let z = f64_param(p, "percent").ok_or_else(|| bad("view.zoom", "missing `percent`"))?.clamp(10.0, 400.0) as u16;
|
||||||
let d = s.doc_mut()?;
|
let d = s.doc_mut()?;
|
||||||
@@ -425,12 +392,6 @@ fn header_footer(s: &mut Session, p: &Json) -> Result<Json> {
|
|||||||
if let Some(on) = bool_param(p, "differentOddEven") {
|
if let Some(on) = bool_param(p, "differentOddEven") {
|
||||||
pr.different_odd_even = on;
|
pr.different_odd_even = on;
|
||||||
}
|
}
|
||||||
if let Some(on) = bool_param(p, "scaleWithDoc") {
|
|
||||||
pr.hf_scale_with_doc = on;
|
|
||||||
}
|
|
||||||
if let Some(on) = bool_param(p, "alignWithMargins") {
|
|
||||||
pr.hf_align_with_margins = on;
|
|
||||||
}
|
|
||||||
if let Some((page, footer, idx, text)) = §ion {
|
if let Some((page, footer, idx, text)) = §ion {
|
||||||
let spec = pr.hf_mut(*page, *footer);
|
let spec = pr.hf_mut(*page, *footer);
|
||||||
let mut parts = super::print::split_header_footer(spec);
|
let mut parts = super::print::split_header_footer(spec);
|
||||||
@@ -444,8 +405,6 @@ fn header_footer(s: &mut Session, p: &Json) -> Result<Json> {
|
|||||||
"footer": pr.footer,
|
"footer": pr.footer,
|
||||||
"differentFirst": pr.different_first,
|
"differentFirst": pr.different_first,
|
||||||
"differentOddEven": pr.different_odd_even,
|
"differentOddEven": pr.different_odd_even,
|
||||||
"scaleWithDoc": pr.hf_scale_with_doc,
|
|
||||||
"alignWithMargins": pr.hf_align_with_margins,
|
|
||||||
"firstHeader": pr.first_header,
|
"firstHeader": pr.first_header,
|
||||||
"firstFooter": pr.first_footer,
|
"firstFooter": pr.first_footer,
|
||||||
"evenHeader": pr.even_header,
|
"evenHeader": pr.even_header,
|
||||||
@@ -519,22 +478,4 @@ mod tests {
|
|||||||
let old: crate::Prefs = serde_json::from_str(r#"{"userName": "Ann"}"#).unwrap();
|
let old: crate::Prefs = serde_json::from_str(r#"{"userName": "Ann"}"#).unwrap();
|
||||||
assert!(old.formula_bar);
|
assert!(old.formula_bar);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn status_bar_statistics_default_to_average_count_and_sum_and_turn_on_one_at_a_time() {
|
|
||||||
let mut s = Session::new();
|
|
||||||
let all = json!({"average": true, "count": true, "numericalCount": false, "minimum": false, "maximum": false, "sum": true});
|
|
||||||
assert_eq!(s.execute("view.statusBar", json!({})).unwrap(), all);
|
|
||||||
let r = s.execute("view.statusBar", json!({"minimum": true, "sum": false, "count": "yes"})).unwrap();
|
|
||||||
assert_eq!(r, json!({"average": true, "count": true, "numericalCount": false, "minimum": true, "maximum": false, "sum": false}));
|
|
||||||
assert!(s.prefs.status_bar.minimum && !s.prefs.status_bar.sum);
|
|
||||||
// A prefs.json saved before the option existed, or with only some of the six, keeps the defaults for the rest.
|
|
||||||
let old: crate::Prefs = serde_json::from_str(r#"{"userName": "Ann"}"#).unwrap();
|
|
||||||
assert_eq!(old.status_bar, crate::StatusBarStats::default());
|
|
||||||
let part: crate::Prefs = serde_json::from_str(r#"{"statusBar": {"maximum": true}}"#).unwrap();
|
|
||||||
assert!(part.status_bar.maximum && part.status_bar.average && part.status_bar.sum && !part.status_bar.minimum);
|
|
||||||
let saved = serde_json::to_value(&s.prefs).unwrap();
|
|
||||||
assert_eq!(saved["statusBar"]["minimum"], true);
|
|
||||||
assert_eq!(saved["statusBar"]["numericalCount"], false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-147
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, days_in_month, serial_from_ymd};
|
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, days_in_month, serial_from_ymd};
|
||||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||||
use gridcraft_model::{Cell, Formula, Workbook};
|
use gridcraft_model::{Cell, Formula};
|
||||||
use gridcraft_numfmt::{FormatKind, NumberFormat};
|
use gridcraft_numfmt::{FormatKind, NumberFormat};
|
||||||
|
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
@@ -23,43 +23,15 @@ pub enum FillMode {
|
|||||||
ValuesOnly,
|
ValuesOnly,
|
||||||
/// Fill Series from the right-drag menu: extends like a plain drag, and a lone number counts up.
|
/// Fill Series from the right-drag menu: extends like a plain drag, and a lone number counts up.
|
||||||
Extend,
|
Extend,
|
||||||
/// Fill Days, Fill Weekdays, Fill Months or Fill Years: a source of dates steps by that unit.
|
|
||||||
/// A lone date steps by one unit; two or more step by the difference between the first two,
|
|
||||||
/// counted in that unit (one unit when they fall in the same one). A source that isn't dates
|
|
||||||
/// extends like a plain drag.
|
|
||||||
Dates(DateUnit),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The step of a [`FillMode::Dates`] fill.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum DateUnit {
|
|
||||||
Day,
|
|
||||||
/// Monday to Friday: Saturday and Sunday are skipped.
|
|
||||||
Weekday,
|
|
||||||
Month,
|
|
||||||
Year,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FillMode {
|
impl FillMode {
|
||||||
/// The modes that continue the source's series rather than copy it.
|
/// The modes that continue the source's series rather than copy it.
|
||||||
fn extends_series(self) -> bool {
|
fn extends_series(self) -> bool {
|
||||||
matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly | FillMode::Dates(_))
|
matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a number format shows a calendar date (`YYYY-MM-DD` and `m/d/yyyy h:mm` do; `0 "days"`
|
|
||||||
/// and `h:mm` don't).
|
|
||||||
fn is_date_format(fmt: &str) -> bool {
|
|
||||||
NumberFormat::parse(fmt).kind() == FormatKind::Date
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the first cell of `src` holds a date, so a fill from it can step by days, weekdays,
|
|
||||||
/// months or years: a number (not a formula) shown in a date format.
|
|
||||||
pub fn starts_with_date(wb: &Workbook, sheet: usize, src: RangeRef) -> bool {
|
|
||||||
let Some(cell) = wb.sheet(sheet).and_then(|sh| sh.cell(src.start)) else { return false };
|
|
||||||
cell.formula.is_none() && cell.value.is_number() && is_date_format(wb.styles.get(cell.style).num_fmt.as_str())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Built-in custom lists.
|
/// Built-in custom lists.
|
||||||
fn lists() -> Vec<Vec<String>> {
|
fn lists() -> Vec<Vec<String>> {
|
||||||
let short_days: Vec<String> = WEEKDAYS.iter().map(|d| d.get(..3).unwrap_or(d).to_string()).collect();
|
let short_days: Vec<String> = WEEKDAYS.iter().map(|d| d.get(..3).unwrap_or(d).to_string()).collect();
|
||||||
@@ -130,12 +102,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
|||||||
};
|
};
|
||||||
let fmt = |c: &(CellRef, Option<Cell>)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str();
|
let fmt = |c: &(CellRef, Option<Cell>)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str();
|
||||||
let pattern = if mode.extends_series() || mode == FillMode::Alternate {
|
let pattern = if mode.extends_series() || mode == FillMode::Alternate {
|
||||||
let first_fmt = src_cells.first().map(fmt).unwrap_or_default();
|
let detected = Pattern::detect(&src_cells, &custom, cx.wb.date_system, src_cells.first().map(fmt).unwrap_or_default());
|
||||||
let dates = match mode {
|
|
||||||
FillMode::Dates(unit) => Pattern::dates(&src_cells, unit, cx.wb.date_system, first_fmt),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
let detected = dates.unwrap_or_else(|| Pattern::detect(&src_cells, &custom, cx.wb.date_system, first_fmt));
|
|
||||||
match (mode, detected) {
|
match (mode, detected) {
|
||||||
(FillMode::Alternate, p) | (FillMode::Extend, p @ Pattern::Copy) => p.alternate(&src_cells),
|
(FillMode::Alternate, p) | (FillMode::Extend, p @ Pattern::Copy) => p.alternate(&src_cells),
|
||||||
(_, p) => p,
|
(_, p) => p,
|
||||||
@@ -163,7 +130,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
|||||||
writes.push((*t, Some(existing)));
|
writes.push((*t, Some(existing)));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend | FillMode::Dates(_) => {
|
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend => {
|
||||||
let style = if mode == FillMode::ValuesOnly { sh.cell(*t).map(|c| c.style).unwrap_or_default() } else { cell.style };
|
let style = if mode == FillMode::ValuesOnly { sh.cell(*t).map(|c| c.style).unwrap_or_default() } else { cell.style };
|
||||||
if let Some(f) = &cell.formula {
|
if let Some(f) = &cell.formula {
|
||||||
if let Some(e) = f.expr() {
|
if let Some(e) = f.expr() {
|
||||||
@@ -237,63 +204,9 @@ enum Pattern {
|
|||||||
months: i64,
|
months: i64,
|
||||||
sys: gridcraft_core::DateSystem,
|
sys: gridcraft_core::DateSystem,
|
||||||
},
|
},
|
||||||
/// Dates stepping by weekdays, skipping Saturday and Sunday.
|
|
||||||
Weekdays {
|
|
||||||
last: f64,
|
|
||||||
first: f64,
|
|
||||||
step: i64,
|
|
||||||
sys: gridcraft_core::DateSystem,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Pattern {
|
impl Pattern {
|
||||||
/// The pattern of Fill Days, Fill Weekdays, Fill Months or Fill Years, or `None` when the
|
|
||||||
/// source isn't all dates (numbers, not formulas, under a date format in its first cell).
|
|
||||||
fn dates(cells: &[(CellRef, Option<Cell>)], unit: DateUnit, sys: gridcraft_core::DateSystem, fmt: &str) -> Option<Pattern> {
|
|
||||||
if !is_date_format(fmt) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut nums = Vec::with_capacity(cells.len());
|
|
||||||
for (_, cell) in cells {
|
|
||||||
let cell = cell.as_ref()?;
|
|
||||||
if cell.formula.is_some() || !cell.value.is_number() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
nums.push(cell.value.as_f64()?);
|
|
||||||
}
|
|
||||||
let first = *nums.first()?;
|
|
||||||
let last = *nums.last()?;
|
|
||||||
let second = nums.get(1).copied();
|
|
||||||
// The difference between the first two dates in the unit, and one unit when they fall in
|
|
||||||
// the same one.
|
|
||||||
let at_least_one = |n: i64| if n == 0 { 1 } else { n };
|
|
||||||
Some(match unit {
|
|
||||||
DateUnit::Day => {
|
|
||||||
let days = second.map_or(1.0, |b| b.floor() - first.floor());
|
|
||||||
Pattern::Linear { last, first, step: if days == 0.0 { 1.0 } else { days } }
|
|
||||||
}
|
|
||||||
DateUnit::Weekday => {
|
|
||||||
let step = match second {
|
|
||||||
Some(b) => at_least_one(weekdays_between(sys, first, b)?),
|
|
||||||
None => 1,
|
|
||||||
};
|
|
||||||
Pattern::Weekdays { last, first, step, sys }
|
|
||||||
}
|
|
||||||
DateUnit::Month | DateUnit::Year => {
|
|
||||||
let months = match second {
|
|
||||||
Some(b) => {
|
|
||||||
let (a, b) = (datetime_from_serial(sys, first)?, datetime_from_serial(sys, b)?);
|
|
||||||
let years = b.year as i64 - a.year as i64;
|
|
||||||
if unit == DateUnit::Year { at_least_one(years) * 12 } else { at_least_one(years * 12 + b.month as i64 - a.month as i64) }
|
|
||||||
}
|
|
||||||
None if unit == DateUnit::Year => 12,
|
|
||||||
None => 1,
|
|
||||||
};
|
|
||||||
Pattern::Months { last, first, months, sys }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detect(cells: &[(CellRef, Option<Cell>)], custom: &[Vec<String>], sys: gridcraft_core::DateSystem, fmt: &str) -> Pattern {
|
fn detect(cells: &[(CellRef, Option<Cell>)], custom: &[Vec<String>], sys: gridcraft_core::DateSystem, fmt: &str) -> Pattern {
|
||||||
let vals: Vec<Value> = cells.iter().map(|(_, c)| c.as_ref().map(|c| c.value.clone()).unwrap_or_default()).collect();
|
let vals: Vec<Value> = cells.iter().map(|(_, c)| c.as_ref().map(|c| c.value.clone()).unwrap_or_default()).collect();
|
||||||
if cells.iter().any(|(_, c)| c.as_ref().is_some_and(|c| c.formula.is_some())) {
|
if cells.iter().any(|(_, c)| c.as_ref().is_some_and(|c| c.formula.is_some())) {
|
||||||
@@ -430,72 +343,20 @@ impl Pattern {
|
|||||||
let s = add_months_clamped(*sys, base, months.checked_mul(k * sign)?)?;
|
let s = add_months_clamped(*sys, base, months.checked_mul(k * sign)?)?;
|
||||||
Some(Value::number(s))
|
Some(Value::number(s))
|
||||||
}
|
}
|
||||||
Pattern::Weekdays { last, first, step, sys } => {
|
|
||||||
let base = if forward { *last } else { *first };
|
|
||||||
Some(Value::number(add_weekdays(*sys, base, step.checked_mul(k)?.checked_mul(sign)?)?))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `serial` falls on Monday to Friday; `None` outside the supported date range.
|
|
||||||
fn is_weekday(sys: gridcraft_core::DateSystem, serial: f64) -> Option<bool> {
|
|
||||||
datetime_from_serial(sys, serial).map(|d| d.weekday != 0 && d.weekday != 6)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `serial` moved by `n` weekdays (back when `n` is negative), keeping its time of day. A start on
|
|
||||||
/// a Saturday or Sunday moves to the next (or previous) weekday for the first step.
|
|
||||||
fn add_weekdays(sys: gridcraft_core::DateSystem, serial: f64, n: i64) -> Option<f64> {
|
|
||||||
if n == 0 {
|
|
||||||
return Some(serial);
|
|
||||||
}
|
|
||||||
let dir = n.signum() as f64;
|
|
||||||
let mut d = serial;
|
|
||||||
let next_weekday = |d: &mut f64| -> Option<()> {
|
|
||||||
loop {
|
|
||||||
*d += dir;
|
|
||||||
if is_weekday(sys, *d)? {
|
|
||||||
return Some(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
next_weekday(&mut d)?;
|
|
||||||
// From a weekday, every seven days hold five weekdays.
|
|
||||||
let rest = n.unsigned_abs() - 1;
|
|
||||||
d += dir * (rest / 5 * 7) as f64;
|
|
||||||
for _ in 0..rest % 5 {
|
|
||||||
next_weekday(&mut d)?;
|
|
||||||
}
|
|
||||||
datetime_from_serial(sys, d)?;
|
|
||||||
Some(d)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How many weekdays lie after `a` up to and including `b` (negative when `b` comes first),
|
|
||||||
/// counting whole days; `None` outside the supported date range.
|
|
||||||
fn weekdays_between(sys: gridcraft_core::DateSystem, a: f64, b: f64) -> Option<i64> {
|
|
||||||
let (lo, hi, sign) = if b >= a { (a.floor(), b.floor(), 1) } else { (b.floor(), a.floor(), -1) };
|
|
||||||
let days = (hi - lo) as i64;
|
|
||||||
let mut count = days / 7 * 5;
|
|
||||||
let mut d = lo + (days / 7 * 7) as f64;
|
|
||||||
for _ in 0..days % 7 {
|
|
||||||
d += 1.0;
|
|
||||||
if is_weekday(sys, d)? {
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(count * sign)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `serial` shifted by `months` calendar months, keeping the day of month but clamping it to the
|
/// `serial` shifted by `months` calendar months, keeping the day of month but clamping it to the
|
||||||
/// target month's last day (Jan 31 + 1 month = Feb 28/29). Always measured from the original date,
|
/// target month's last day (Jan 31 + 1 month = Feb 28/29). Always measured from the original date,
|
||||||
/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30. The time of day is kept.
|
/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30.
|
||||||
fn add_months_clamped(sys: gridcraft_core::DateSystem, serial: f64, months: i64) -> Option<f64> {
|
fn add_months_clamped(sys: gridcraft_core::DateSystem, serial: f64, months: i64) -> Option<f64> {
|
||||||
let d = datetime_from_serial(sys, serial)?;
|
let d = datetime_from_serial(sys, serial)?;
|
||||||
let total = (d.year as i64).checked_mul(12)?.checked_add(d.month as i64 - 1)?.checked_add(months)?;
|
let total = (d.year as i64).checked_mul(12)?.checked_add(d.month as i64 - 1)?.checked_add(months)?;
|
||||||
let year = total.div_euclid(12);
|
let year = total.div_euclid(12);
|
||||||
let month = total.rem_euclid(12) + 1;
|
let month = total.rem_euclid(12) + 1;
|
||||||
let last = days_in_month(i32::try_from(year).ok()?, month as u32);
|
let last = days_in_month(i32::try_from(year).ok()?, month as u32);
|
||||||
serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64)).map(|day| day + serial - serial.floor())
|
serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn round15(x: f64) -> f64 {
|
fn round15(x: f64) -> f64 {
|
||||||
@@ -527,9 +388,9 @@ fn ordinal_suffix(n: i64, suffix: &str) -> String {
|
|||||||
/// `Item 007` → ("Item ", 7, "", 3); `3rd` style leading numbers → ("", 3, "rd", 1).
|
/// `Item 007` → ("Item ", 7, "", 3); `3rd` style leading numbers → ("", 3, "rd", 1).
|
||||||
fn split_num(t: &str) -> Option<(String, i64, String, usize)> {
|
fn split_num(t: &str) -> Option<(String, i64, String, usize)> {
|
||||||
let chars: Vec<char> = t.chars().collect();
|
let chars: Vec<char> = t.chars().collect();
|
||||||
let end = chars.iter().rposition(char::is_ascii_digit)?;
|
let end = chars.iter().rposition(|c| c.is_ascii_digit())?;
|
||||||
let mut start = end;
|
let mut start = end;
|
||||||
while start > 0 && chars.get(start - 1).is_some_and(char::is_ascii_digit) {
|
while start > 0 && chars.get(start - 1).is_some_and(|c| c.is_ascii_digit()) {
|
||||||
start -= 1;
|
start -= 1;
|
||||||
}
|
}
|
||||||
let digits: String = chars.get(start..=end)?.iter().collect();
|
let digits: String = chars.get(start..=end)?.iter().collect();
|
||||||
|
|||||||
+7
-96
@@ -209,7 +209,7 @@ fn part_too_large_message(name: &str, part: &str, size: u64) -> String {
|
|||||||
|
|
||||||
/// A CSV opens as a sheet named after the file (like Excel).
|
/// A CSV opens as a sheet named after the file (like Excel).
|
||||||
fn rename_first_sheet(wb: &mut Workbook, path: &str) {
|
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");
|
let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("Sheet1");
|
||||||
let clean: String = stem.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
|
let clean: String = stem.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
|
||||||
let clean = clean.trim_matches('\'').to_string();
|
let clean = clean.trim_matches('\'').to_string();
|
||||||
// "History" is reserved (see `Workbook::check_sheet_name`): keep the default name instead.
|
// "History" is reserved (see `Workbook::check_sheet_name`): keep the default name instead.
|
||||||
@@ -226,7 +226,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
|
|||||||
// An unknown or missing extension is refused rather than written as an XLSX under that name:
|
// An unknown or missing extension is refused rather than written as an XLSX under that name:
|
||||||
// a typo like `.cvs` would otherwise produce a file no other app recognises.
|
// a typo like `.cvs` would otherwise produce a file no other app recognises.
|
||||||
let Some(kind) = FileKind::from_path(path) else {
|
let Some(kind) = FileKind::from_path(path) else {
|
||||||
let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(path);
|
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(path);
|
||||||
return Err(EngineError::Other(format!("{name}: unknown file format. Save as .xlsx, .csv, .tsv, .json or .html.")));
|
return Err(EngineError::Other(format!("{name}: unknown file format. Save as .xlsx, .csv, .tsv, .json or .html.")));
|
||||||
};
|
};
|
||||||
save_bytes_as(wb, path, kind)
|
save_bytes_as(wb, path, kind)
|
||||||
@@ -239,7 +239,7 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
|
|||||||
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
|
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
|
||||||
// the VBA project, so any other name saves without it, as Excel does.
|
// the VBA project, so any other name saves without it, as Excel does.
|
||||||
FileKind::Xlsx => {
|
FileKind::Xlsx => {
|
||||||
let book = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or_default();
|
let book = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or_default();
|
||||||
gridcraft_xlsx::write_xlsx_named(wb, gridcraft_xlsx::XlsxKind::from_path(path), book).map_err(|e| EngineError::Other(e.to_string()))
|
gridcraft_xlsx::write_xlsx_named(wb, gridcraft_xlsx::XlsxKind::from_path(path), book).map_err(|e| EngineError::Other(e.to_string()))
|
||||||
}
|
}
|
||||||
FileKind::Xls => {
|
FileKind::Xls => {
|
||||||
@@ -604,27 +604,14 @@ fn script_sandbox_check(path: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The largest native file the reader still touches: its own cap plus one, used to stop
|
|
||||||
/// reads whose bytes arrive faster than metadata can claim (device files, growing files).
|
|
||||||
const MAX_READ_BYTES: u64 = 2 * 1024 * 1024 * 1024;
|
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn read_file(path: &str) -> Result<Vec<u8>> {
|
pub fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||||
use std::io::Read;
|
|
||||||
script_sandbox_check(path)?;
|
script_sandbox_check(path)?;
|
||||||
let f = std::fs::File::open(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||||
// A symlink to an endless device reports length 0 but streams forever, so metadata
|
if meta.len() > 2 * 1024 * 1024 * 1024 {
|
||||||
// alone never bounds the read: require a real file and cap the bytes actually taken.
|
|
||||||
if !f.metadata().map_err(|e| EngineError::Other(format!("{path}: {e}")))?.is_file() {
|
|
||||||
return Err(EngineError::Other(format!("{path}: only regular files can be opened")));
|
|
||||||
}
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
let mut limited = (&f).take(MAX_READ_BYTES + 1);
|
|
||||||
limited.read_to_end(&mut buf).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
|
||||||
if buf.len() as u64 > MAX_READ_BYTES {
|
|
||||||
return Err(EngineError::Other("files larger than 2 GB aren't supported".into()));
|
return Err(EngineError::Other("files larger than 2 GB aren't supported".into()));
|
||||||
}
|
}
|
||||||
Ok(buf)
|
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
@@ -635,35 +622,10 @@ pub fn read_file(path: &str) -> Result<Vec<u8>> {
|
|||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||||
use std::io::Write;
|
|
||||||
script_sandbox_check(path)?;
|
script_sandbox_check(path)?;
|
||||||
// Write to a temporary file, then rename: a crash never leaves a half-written workbook.
|
// Write to a temporary file, then rename: a crash never leaves a half-written workbook.
|
||||||
// The temporary file is created exclusively, so an existing file or symlink at that
|
|
||||||
// path (planted by another local user) makes the save fail instead of truncating the
|
|
||||||
// link's target; and it inherits the destination's access restrictions, so saving a
|
|
||||||
// private workbook doesn't widen who can read it.
|
|
||||||
let tmp = format!("{path}.sctmp");
|
let tmp = format!("{path}.sctmp");
|
||||||
let prior = std::fs::metadata(path).ok().map(|m| m.permissions());
|
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| {
|
||||||
let mut opts = std::fs::OpenOptions::new();
|
|
||||||
opts.write(true).create_new(true);
|
|
||||||
#[cfg(unix)]
|
|
||||||
if let Some(p) = &prior {
|
|
||||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
|
||||||
opts.mode(p.mode());
|
|
||||||
}
|
|
||||||
let mut f = opts.open(&tmp).map_err(|e| {
|
|
||||||
let _ = std::fs::remove_file(&tmp);
|
|
||||||
EngineError::Other(format!("{path}: {e}"))
|
|
||||||
})?;
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
if let Some(p) = &prior {
|
|
||||||
let _ = f.set_permissions(p.clone());
|
|
||||||
}
|
|
||||||
f.write_all(bytes).and_then(|()| f.flush()).map_err(|e| {
|
|
||||||
let _ = std::fs::remove_file(&tmp);
|
|
||||||
EngineError::Other(format!("{path}: {e}"))
|
|
||||||
})?;
|
|
||||||
std::fs::rename(&tmp, path).map_err(|e| {
|
|
||||||
// A write or rename that fails (full disk, the workbook locked by another app) leaves
|
// A write or rename that fails (full disk, the workbook locked by another app) leaves
|
||||||
// no temporary file beside the user's workbook.
|
// no temporary file beside the user's workbook.
|
||||||
let _ = std::fs::remove_file(&tmp);
|
let _ = std::fs::remove_file(&tmp);
|
||||||
@@ -765,57 +727,6 @@ mod tests {
|
|||||||
range_html(wb, 0, r, &crate::display::range_text(wb, 0, r)).unwrap()
|
range_html(wb, 0, r, &crate::display::range_text(wb, 0, r)).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
#[test]
|
|
||||||
fn save_fails_upfront_when_the_temp_name_is_taken() {
|
|
||||||
// Another local user who can write the directory must not be able to point the
|
|
||||||
// save's predictable temporary name at their own file: creation is exclusive.
|
|
||||||
let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-temp-name-{}", std::process::id()));
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
let victim = dir.join("victim.txt");
|
|
||||||
std::fs::write(&victim, b"original").unwrap();
|
|
||||||
let dest = dir.join("book.xlsx");
|
|
||||||
std::os::unix::fs::symlink(&victim, dir.join("book.xlsx.sctmp")).unwrap();
|
|
||||||
let err = write_file(dest.to_str().unwrap(), b"new").unwrap_err();
|
|
||||||
assert_eq!(std::fs::read(&victim).unwrap(), b"original");
|
|
||||||
assert!(!dir.join("book.xlsx").exists(), "{err}");
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn save_keeps_the_destination_access_restrictions() {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-permissions-{}", std::process::id()));
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
let dest = dir.join("private.xlsx");
|
|
||||||
std::fs::write(&dest, b"v1").unwrap();
|
|
||||||
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o600)).unwrap();
|
|
||||||
write_file(dest.to_str().unwrap(), b"v2").unwrap();
|
|
||||||
let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777;
|
|
||||||
assert_eq!(mode, 0o600);
|
|
||||||
// And a brand-new file is created with the restrictive, not permissive, default.
|
|
||||||
let fresh = dir.join("new.xlsx");
|
|
||||||
write_file(fresh.to_str().unwrap(), b"v").unwrap();
|
|
||||||
let mode = std::fs::metadata(&fresh).unwrap().permissions().mode() & 0o777;
|
|
||||||
assert!(mode & 0o022 == 0, "mode {mode:o}");
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
#[test]
|
|
||||||
fn open_refuses_directories_and_unknowable_lengths() {
|
|
||||||
let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-open-{}", std::process::id()));
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
assert!(read_file(dir.to_str().unwrap()).is_err(), "a directory is not a regular file");
|
|
||||||
assert!(read_file(dir.join("missing").to_str().unwrap()).is_err());
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn html_export_refuses_an_outsized_grid_instead_of_growing_forever() {
|
fn html_export_refuses_an_outsized_grid_instead_of_growing_forever() {
|
||||||
// #2387: the used rectangle alone bounds the output (every row writes the whole
|
// #2387: the used rectangle alone bounds the output (every row writes the whole
|
||||||
|
|||||||
@@ -199,9 +199,6 @@ pub struct DocState {
|
|||||||
/// workbook is opened from a file, carried by content pasted from such a workbook, and never
|
/// workbook is opened from a file, carried by content pasted from such a workbook, and never
|
||||||
/// changed by a trust decision.
|
/// changed by a trust decision.
|
||||||
pub trust: Trust,
|
pub trust: Trust,
|
||||||
/// The references `edit.goTo` with `record: true` went to in this document, newest first,
|
|
||||||
/// without names, each area written with its sheet (`Sheet1!B14:C22,Sheet1!F19:G30`).
|
|
||||||
pub go_to_recent: Vec<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||||
@@ -288,7 +285,6 @@ impl DocState {
|
|||||||
content_kind: None,
|
content_kind: None,
|
||||||
scripts_trusted,
|
scripts_trusted,
|
||||||
trust,
|
trust,
|
||||||
go_to_recent: vec![],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn is_dirty(&self) -> bool {
|
pub fn is_dirty(&self) -> bool {
|
||||||
@@ -351,27 +347,6 @@ pub struct Prefs {
|
|||||||
pub user_dictionary: Vec<String>,
|
pub user_dictionary: Vec<String>,
|
||||||
/// View › Show › Formula Bar (an application option in Excel, not saved in the file).
|
/// View › Show › Formula Bar (an application option in Excel, not saved in the file).
|
||||||
pub formula_bar: bool,
|
pub formula_bar: bool,
|
||||||
/// The statistics the status bar shows for a selection (right-click the status bar).
|
|
||||||
pub status_bar: StatusBarStats,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The selection statistics shown on the status bar, set with `view.statusBar`. Excel turns on
|
|
||||||
/// Average, Count and Sum by default.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
|
|
||||||
#[serde(default, rename_all = "camelCase")]
|
|
||||||
pub struct StatusBarStats {
|
|
||||||
pub average: bool,
|
|
||||||
pub count: bool,
|
|
||||||
pub numerical_count: bool,
|
|
||||||
pub minimum: bool,
|
|
||||||
pub maximum: bool,
|
|
||||||
pub sum: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for StatusBarStats {
|
|
||||||
fn default() -> Self {
|
|
||||||
StatusBarStats { average: true, count: true, numerical_count: false, minimum: false, maximum: false, sum: true }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Prefs {
|
impl Default for Prefs {
|
||||||
@@ -386,7 +361,6 @@ impl Default for Prefs {
|
|||||||
r1c1: false,
|
r1c1: false,
|
||||||
user_dictionary: vec![],
|
user_dictionary: vec![],
|
||||||
formula_bar: true,
|
formula_bar: true,
|
||||||
status_bar: StatusBarStats::default(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -398,9 +372,6 @@ pub enum UiRequest {
|
|||||||
Message(String),
|
Message(String),
|
||||||
/// Start editing the active cell (F2) with optional initial text.
|
/// Start editing the active cell (F2) with optional initial text.
|
||||||
EditCell(Option<String>),
|
EditCell(Option<String>),
|
||||||
/// Put a defined name at the caret of the cell being edited, in place of selected text; with
|
|
||||||
/// no edit under way, start editing the active cell with `=` and the name.
|
|
||||||
InsertName(String),
|
|
||||||
OpenUrl(String),
|
OpenUrl(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,11 +572,8 @@ impl Session {
|
|||||||
let result = match result {
|
let result = match result {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(p) => {
|
Err(p) => {
|
||||||
let msg = p
|
let msg =
|
||||||
.downcast_ref::<&str>()
|
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
|
||||||
.map(ToString::to_string)
|
|
||||||
.or_else(|| p.downcast_ref::<String>().cloned())
|
|
||||||
.unwrap_or_else(|| "panic".into());
|
|
||||||
// Restore the workbook and its history as they were, and drop the undo groups the
|
// Restore the workbook and its history as they were, and drop the undo groups the
|
||||||
// command opened.
|
// command opened.
|
||||||
self.groups.retain(|g| g.id < first_group);
|
self.groups.retain(|g| g.id < first_group);
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
//! Outline groups (Data ▸ Group) of a sheet's rows or columns: the runs of lines at or above
|
//! Outline groups (Data ▸ Group) of a sheet's rows or columns: the runs of lines at or above
|
||||||
//! each outline level, as the grid draws them (brackets, − / + buttons, level buttons) and the
|
//! each outline level, as the grid draws them (brackets, − / + buttons, level buttons) and the
|
||||||
//! Show / Hide Detail commands act on them. Summary rows sit below their detail and summary
|
//! Show / Hide Detail commands act on them. Summary rows sit below their detail and summary
|
||||||
//! columns right of it, or above / left of it when the sheet's outline settings say so
|
//! columns right of it (Excel's defaults; the model has no setting for the other way round).
|
||||||
//! ([`Sheet::summary_below`], [`Sheet::summary_right`]).
|
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use gridcraft_model::{LineInfo, Sheet};
|
use gridcraft_model::LineInfo;
|
||||||
|
|
||||||
/// The deepest outline level XLSX allows.
|
/// The deepest outline level XLSX allows.
|
||||||
pub const MAX_LEVEL: u8 = 7;
|
pub const MAX_LEVEL: u8 = 7;
|
||||||
|
|
||||||
/// One group: lines `start..=end` at outline level `level` or deeper, with its summary line
|
/// One group: lines `start..=end` at outline level `level` or deeper, with its summary line
|
||||||
/// just after `end`, or just before `start` when `summary_before` is set.
|
/// just after `end`.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub struct OutlineGroup {
|
pub struct OutlineGroup {
|
||||||
pub level: u8,
|
pub level: u8,
|
||||||
@@ -20,43 +19,25 @@ pub struct OutlineGroup {
|
|||||||
pub end: u32,
|
pub end: u32,
|
||||||
/// Every detail line is hidden: the button shows +.
|
/// Every detail line is hidden: the button shows +.
|
||||||
pub collapsed: bool,
|
pub collapsed: bool,
|
||||||
/// The summary line is above (rows) or left of (columns) the detail.
|
|
||||||
pub summary_before: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OutlineGroup {
|
impl OutlineGroup {
|
||||||
/// The summary line, where the − / + button sits.
|
/// The summary line, where the − / + button sits.
|
||||||
pub fn summary(&self) -> u32 {
|
pub fn summary(&self) -> u32 {
|
||||||
if self.summary_before { self.start.saturating_sub(1) } else { self.end.saturating_add(1) }
|
self.end.saturating_add(1)
|
||||||
}
|
|
||||||
/// Whether the summary line exists: a group that starts at the first line (summary before)
|
|
||||||
/// or ends at the last one (summary after) has none.
|
|
||||||
pub fn has_summary(&self) -> bool {
|
|
||||||
!self.contains(self.summary())
|
|
||||||
}
|
}
|
||||||
pub fn contains(&self, line: u32) -> bool {
|
pub fn contains(&self, line: u32) -> bool {
|
||||||
line >= self.start && line <= self.end
|
line >= self.start && line <= self.end
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the sheet's summary rows (`rows`) or summary columns sit before their detail.
|
|
||||||
pub fn summary_before(sh: &Sheet, rows: bool) -> bool {
|
|
||||||
if rows { !sh.summary_below } else { !sh.summary_right }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The deepest outline level used by these lines (0 = no outline).
|
/// The deepest outline level used by these lines (0 = no outline).
|
||||||
pub fn max_level(lines: &BTreeMap<u32, LineInfo>) -> u8 {
|
pub fn max_level(lines: &BTreeMap<u32, LineInfo>) -> u8 {
|
||||||
lines.values().map(|l| l.outline.min(MAX_LEVEL)).max().unwrap_or(0)
|
lines.values().map(|l| l.outline.min(MAX_LEVEL)).max().unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every group, with its summary line after the detail; see [`groups_with`].
|
/// Every group, ordered by where it ends (inner groups before the group around them).
|
||||||
pub fn groups(lines: &BTreeMap<u32, LineInfo>) -> Vec<OutlineGroup> {
|
pub fn groups(lines: &BTreeMap<u32, LineInfo>) -> Vec<OutlineGroup> {
|
||||||
groups_with(lines, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every group, ordered by where it ends (inner groups before the group around them), with its
|
|
||||||
/// summary line before the detail when `summary_before` is set.
|
|
||||||
pub fn groups_with(lines: &BTreeMap<u32, LineInfo>, summary_before: bool) -> Vec<OutlineGroup> {
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
// Open group per level (index level - 1): start line and whether all its lines are hidden.
|
// Open group per level (index level - 1): start line and whether all its lines are hidden.
|
||||||
let mut open: [Option<(u32, bool)>; MAX_LEVEL as usize] = [None; MAX_LEVEL as usize];
|
let mut open: [Option<(u32, bool)>; MAX_LEVEL as usize] = [None; MAX_LEVEL as usize];
|
||||||
@@ -66,7 +47,7 @@ pub fn groups_with(lines: &BTreeMap<u32, LineInfo>, summary_before: bool) -> Vec
|
|||||||
if let Some(slot) = open.get_mut(level as usize - 1)
|
if let Some(slot) = open.get_mut(level as usize - 1)
|
||||||
&& let Some((start, hidden)) = slot.take()
|
&& let Some((start, hidden)) = slot.take()
|
||||||
{
|
{
|
||||||
out.push(OutlineGroup { level, start, end, collapsed: hidden, summary_before });
|
out.push(OutlineGroup { level, start, end, collapsed: hidden });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -121,9 +102,9 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
g,
|
g,
|
||||||
vec![
|
vec![
|
||||||
OutlineGroup { level: 2, start: 5, end: 6, collapsed: false, summary_before: false },
|
OutlineGroup { level: 2, start: 5, end: 6, collapsed: false },
|
||||||
OutlineGroup { level: 1, start: 4, end: 7, collapsed: false, summary_before: false },
|
OutlineGroup { level: 1, start: 4, end: 7, collapsed: false },
|
||||||
OutlineGroup { level: 1, start: 11, end: 12, collapsed: true, summary_before: false },
|
OutlineGroup { level: 1, start: 11, end: 12, collapsed: true },
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(g[1].summary(), 8);
|
assert_eq!(g[1].summary(), 8);
|
||||||
@@ -148,22 +129,5 @@ mod tests {
|
|||||||
assert_eq!(g.len(), 7);
|
assert_eq!(g.len(), 7);
|
||||||
assert!(g.iter().all(|g| g.start == u32::MAX - 1 && g.end == u32::MAX));
|
assert!(g.iter().all(|g| g.start == u32::MAX - 1 && g.end == u32::MAX));
|
||||||
assert_eq!(g[0].summary(), u32::MAX);
|
assert_eq!(g[0].summary(), u32::MAX);
|
||||||
assert!(!g[0].has_summary());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn summary_before_the_detail() {
|
|
||||||
// Rows 5..8 at level 1 with 6..7 at level 2: the summaries are rows 4 and 5.
|
|
||||||
let l = lines(&[(4, 1, false), (5, 2, false), (6, 2, false), (7, 1, false), (0, 1, false)]);
|
|
||||||
let g = groups_with(&l, true);
|
|
||||||
let at = |level: u8, start: u32| g.iter().find(|g| g.level == level && g.start == start).copied();
|
|
||||||
assert_eq!(at(1, 4).map(|g| (g.summary(), g.has_summary())), Some((3, true)));
|
|
||||||
assert_eq!(at(2, 5).map(|g| (g.summary(), g.has_summary())), Some((4, true)));
|
|
||||||
// A group on the first line has no line before it.
|
|
||||||
assert_eq!(at(1, 0).map(|g| (g.summary(), g.has_summary())), Some((0, false)));
|
|
||||||
let mut sh = Sheet::new("S");
|
|
||||||
assert!(!summary_before(&sh, true) && !summary_before(&sh, false));
|
|
||||||
sh.summary_below = false;
|
|
||||||
assert!(summary_before(&sh, true) && !summary_before(&sh, false));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,12 +29,6 @@ pub struct PageGrid {
|
|||||||
pub gap: f64,
|
pub gap: f64,
|
||||||
/// Print scale (1.0 = 100%): screen sizes of cells are `zoom × scale`.
|
/// Print scale (1.0 = 100%): screen sizes of cells are `zoom × scale`.
|
||||||
pub scale: f32,
|
pub scale: f32,
|
||||||
/// Left and right margins of headers and footers in layout units (the side margins, or a
|
|
||||||
/// fixed 0.7 inch, at most a quarter of the page width, with Align with Page Margins off).
|
|
||||||
pub hf_sides: [f64; 2],
|
|
||||||
/// Layout units per sheet pixel of header and footer text: 1 with Scale with Document on,
|
|
||||||
/// otherwise `1 / scale`, so the text keeps its point size on paper.
|
|
||||||
pub hf_text_k: f32,
|
|
||||||
/// Paper size in points (after orientation), for the rulers.
|
/// Paper size in points (after orientation), for the rulers.
|
||||||
pub paper_pt: (f32, f32),
|
pub paper_pt: (f32, f32),
|
||||||
pub row_bands: Vec<(u32, u32)>,
|
pub row_bands: Vec<(u32, u32)>,
|
||||||
@@ -71,8 +65,6 @@ impl PageGrid {
|
|||||||
let s = f64::from(lay.scale.clamp(0.1, 4.0));
|
let s = f64::from(lay.scale.clamp(0.1, 4.0));
|
||||||
let to_layout = |pt: f32| f64::from(pt) / f64::from(PX_TO_PT) / s;
|
let to_layout = |pt: f32| f64::from(pt) / f64::from(PX_TO_PT) / s;
|
||||||
let margins = lay.margins.map(to_layout);
|
let margins = lay.margins.map(to_layout);
|
||||||
let (hf_left, hf_right) = lay.hf_sides();
|
|
||||||
let hf_text_k = lay.hf_text_scale() / s as f32;
|
|
||||||
let band_range = |bands: &[(u32, u32)], lo: u32, hi: u32| {
|
let band_range = |bands: &[(u32, u32)], lo: u32, hi: u32| {
|
||||||
let first = bands.iter().position(|b| b.1 >= lo).unwrap_or(0);
|
let first = bands.iter().position(|b| b.1 >= lo).unwrap_or(0);
|
||||||
let last = bands.iter().rposition(|b| b.0 <= hi).unwrap_or(first).max(first);
|
let last = bands.iter().rposition(|b| b.0 <= hi).unwrap_or(first).max(first);
|
||||||
@@ -87,8 +79,6 @@ impl PageGrid {
|
|||||||
margins,
|
margins,
|
||||||
gap: f64::from(PAGE_GAP) / s,
|
gap: f64::from(PAGE_GAP) / s,
|
||||||
scale: lay.scale,
|
scale: lay.scale,
|
||||||
hf_sides: [to_layout(hf_left), to_layout(hf_right)],
|
|
||||||
hf_text_k,
|
|
||||||
paper_pt: (lay.page_w, lay.page_h),
|
paper_pt: (lay.page_w, lay.page_h),
|
||||||
rows_complete: row_bands.last().is_some_and(|b| b.1 >= MAX_ROWS - 1),
|
rows_complete: row_bands.last().is_some_and(|b| b.1 >= MAX_ROWS - 1),
|
||||||
cols_complete: col_bands.last().is_some_and(|b| b.1 >= MAX_COLS - 1),
|
cols_complete: col_bands.last().is_some_and(|b| b.1 >= MAX_COLS - 1),
|
||||||
@@ -184,12 +174,11 @@ impl PageGrid {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The header or footer section under the point: the top or bottom margin of a page, split
|
/// The header or footer section under the point: the top or bottom margin of a page, split
|
||||||
/// into thirds between the header and footer side margins.
|
/// into thirds between the side margins.
|
||||||
pub fn hf_zone_at(&self, x: f64, y: f64) -> Option<((usize, usize), HfZone)> {
|
pub fn hf_zone_at(&self, x: f64, y: f64) -> Option<((usize, usize), HfZone)> {
|
||||||
let (i, j) = self.page_at(x, y)?;
|
let (i, j) = self.page_at(x, y)?;
|
||||||
let (px, py, _, _) = self.page_rect(i, j);
|
let (px, py, _, _) = self.page_rect(i, j);
|
||||||
let [_, _, t, b, ..] = self.margins;
|
let [l, r, t, b, ..] = self.margins;
|
||||||
let [l, r] = self.hf_sides;
|
|
||||||
let footer = if y < py + t {
|
let footer = if y < py + t {
|
||||||
false
|
false
|
||||||
} else if y >= py + self.page_h - b {
|
} else if y >= py + self.page_h - b {
|
||||||
@@ -312,31 +301,6 @@ mod tests {
|
|||||||
assert!(g.is_data_page(0, 0) && !g.is_data_page(1, 0));
|
assert!(g.is_data_page(0, 0) && !g.is_data_page(1, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn header_zones_and_text_follow_scale_with_document_and_align_with_page_margins() {
|
|
||||||
let mut wb = wb_with(10, 3);
|
|
||||||
if let Some(sh) = wb.sheet_mut(0) {
|
|
||||||
sh.print.scale = 50;
|
|
||||||
sh.print.margins = [2.0, 2.0, 0.75, 0.75, 0.3, 0.3];
|
|
||||||
}
|
|
||||||
let g = PageGrid::build(&wb, 0, 50, 20).unwrap();
|
|
||||||
let (px, py, _, _) = g.page_rect(0, 0);
|
|
||||||
// Both on: the sections sit between the 2-inch side margins and the text scales with the cells.
|
|
||||||
assert_eq!(g.hf_sides, [g.margins[0], g.margins[1]]);
|
|
||||||
assert!((g.hf_text_k - 1.0).abs() < 1e-6);
|
|
||||||
assert_eq!(g.hf_zone_at(px + 1000.0, py + 3.0).map(|z| z.1.section), Some(2));
|
|
||||||
if let Some(sh) = wb.sheet_mut(0) {
|
|
||||||
sh.print.hf_scale_with_doc = false;
|
|
||||||
sh.print.hf_align_with_margins = false;
|
|
||||||
}
|
|
||||||
let g = PageGrid::build(&wb, 0, 50, 20).unwrap();
|
|
||||||
// Both off: 0.7 inch (67.2 sheet pixels, 134.4 layout units at 50%) on each side, and the
|
|
||||||
// text keeps its point size on paper.
|
|
||||||
assert!(g.hf_sides.iter().all(|s| (s - 134.4).abs() < 1e-3), "{:?}", g.hf_sides);
|
|
||||||
assert!((g.hf_text_k - 2.0).abs() < 1e-6);
|
|
||||||
assert_eq!(g.hf_zone_at(px + 1000.0, py + 3.0).map(|z| z.1.section), Some(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn print_scale_and_hostile_settings_stay_bounded() {
|
fn print_scale_and_hostile_settings_stay_bounded() {
|
||||||
let mut wb = wb_with(3, 3);
|
let mut wb = wb_with(3, 3);
|
||||||
|
|||||||
+18
-43
@@ -11,10 +11,7 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use gridcraft_core::date::datetime_from_serial;
|
use gridcraft_core::date::datetime_from_serial;
|
||||||
pub use gridcraft_core::quote_sheet;
|
pub use gridcraft_core::quote_sheet;
|
||||||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, split_sheet};
|
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, split_sheet};
|
||||||
use gridcraft_model::{
|
use gridcraft_model::*;
|
||||||
BorderLine, BorderStyle, Cell, Chart, Color, Fill, HAlign, NumFmt, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs,
|
|
||||||
PivotSort, PivotTable, PivotValue, Series, Style, StyleId, Workbook,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::pivot_calc::CalcSet;
|
use crate::pivot_calc::CalcSet;
|
||||||
|
|
||||||
@@ -819,30 +816,11 @@ pub const NOT_A_VALUE_CELL: &str = "select a value cell in a PivotTable";
|
|||||||
/// on the sheet is no longer what the source computes: the cell's records would be another cell's.
|
/// on the sheet is no longer what the source computes: the cell's records would be another cell's.
|
||||||
pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u32, col: u32) -> Result<Option<(Source, Vec<usize>)>, String> {
|
pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u32, col: u32) -> Result<Option<(Source, Vec<usize>)>, String> {
|
||||||
let src = read_source(wb, &pt.source, pivot_sheet)?;
|
let src = read_source(wb, &pt.source, pivot_sheet)?;
|
||||||
let mut probe = Probe { at: Some((row as usize, col as usize)), hit: None, paths: Some(Vec::new()), cells: None };
|
let mut probe = Probe { at: (row as usize, col as usize), hit: None, paths: Vec::new() };
|
||||||
let out = compute_from(wb, pt, &src, Some(&mut probe))?;
|
let out = compute_from(wb, pt, &src, Some(&mut probe))?;
|
||||||
// Checked first: in a stale report even a cell that computes as a label may show a value.
|
// Excel reads its PivotTable cache; without one, the report written by the last refresh
|
||||||
check_current(wb, pt, pivot_sheet, &out)?;
|
// must still be what the source gives. Checked first: in a stale report even a cell that
|
||||||
let Some((rp, cp)) = probe.hit else { return Ok(None) };
|
// computes as a label may show a value.
|
||||||
let records =
|
|
||||||
probe.paths.unwrap_or_default().into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect();
|
|
||||||
Ok(Some((src, records)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The cells of the report grid (relative to its top-left) where [`detail_records`] finds
|
|
||||||
/// records: its value, subtotal and grand total cells, from one computation of the report. The
|
|
||||||
/// same errors as [`detail_records`].
|
|
||||||
pub fn detail_cells(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<HashSet<(u32, u32)>, String> {
|
|
||||||
let src = read_source(wb, &pt.source, pivot_sheet)?;
|
|
||||||
let mut probe = Probe { at: None, hit: None, paths: None, cells: Some(HashSet::new()) };
|
|
||||||
let out = compute_from(wb, pt, &src, Some(&mut probe))?;
|
|
||||||
check_current(wb, pt, pivot_sheet, &out)?;
|
|
||||||
Ok(probe.cells.unwrap_or_default().into_iter().map(|(r, c)| (r as u32, c as u32)).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An error when the report on the sheet is not what `out` computes. Excel reads its PivotTable
|
|
||||||
/// cache; without one, the report written by the last refresh must still be what the source gives.
|
|
||||||
fn check_current(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, out: &Output) -> Result<(), String> {
|
|
||||||
let sh = wb.sheet(pivot_sheet).ok_or("There's no such sheet.")?;
|
let sh = wb.sheet(pivot_sheet).ok_or("There's no such sheet.")?;
|
||||||
let current = pt.last_range.is_some_and(|r| {
|
let current = pt.last_range.is_some_and(|r| {
|
||||||
r.height() == out.height().max(1)
|
r.height() == out.height().max(1)
|
||||||
@@ -856,17 +834,17 @@ fn check_current(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, out: &Outpu
|
|||||||
if !current {
|
if !current {
|
||||||
return Err("The PivotTable report is out of date. Refresh it first.".into());
|
return Err("The PivotTable report is out of date. Refresh it first.".into());
|
||||||
}
|
}
|
||||||
Ok(())
|
let Some((rp, cp)) = probe.hit else { return Ok(None) };
|
||||||
|
let records = probe.paths.into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect();
|
||||||
|
Ok(Some((src, records)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What [`detail_records`] and [`detail_cells`] ask of a report computation: the row and column
|
/// What [`detail_records`] asks of a report computation: the row and column item paths of the
|
||||||
/// item paths of the value cell at `at`, the paths of every record the filters let through (when
|
/// value cell at `at`, and the paths of every record the filters let through.
|
||||||
/// `paths` is set), and every value cell (when `cells` is set).
|
|
||||||
struct Probe {
|
struct Probe {
|
||||||
at: Option<(usize, usize)>,
|
at: (usize, usize),
|
||||||
hit: Option<(Vec<u32>, Vec<u32>)>,
|
hit: Option<(Vec<u32>, Vec<u32>)>,
|
||||||
paths: Option<Vec<(usize, Vec<u32>, Vec<u32>)>>,
|
paths: Vec<(usize, Vec<u32>, Vec<u32>)>,
|
||||||
cells: Option<HashSet<(usize, usize)>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<&mut Probe>) -> Result<Output, String> {
|
fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<&mut Probe>) -> Result<Output, String> {
|
||||||
@@ -981,8 +959,8 @@ fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<
|
|||||||
if !col_leaves.contains(&cp[..shown]) {
|
if !col_leaves.contains(&cp[..shown]) {
|
||||||
col_leaves.insert(cp[..shown].to_vec());
|
col_leaves.insert(cp[..shown].to_vec());
|
||||||
}
|
}
|
||||||
if let Some(paths) = probe.as_deref_mut().and_then(|pr| pr.paths.as_mut()) {
|
if let Some(pr) = probe.as_deref_mut() {
|
||||||
paths.push((ri, rp.clone(), cp.clone()));
|
pr.paths.push((ri, rp.clone(), cp.clone()));
|
||||||
}
|
}
|
||||||
row_set.insert(rp);
|
row_set.insert(rp);
|
||||||
col_set.insert(cp);
|
col_set.insert(cp);
|
||||||
@@ -1348,13 +1326,10 @@ fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<
|
|||||||
_ => raw,
|
_ => raw,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if let Some(pr) = probe.as_deref_mut() {
|
if let Some(pr) = probe.as_deref_mut()
|
||||||
if pr.at == Some((r, l + j)) {
|
&& pr.at == (r, l + j)
|
||||||
pr.hit = Some((rp.to_vec(), cp.to_vec()));
|
{
|
||||||
}
|
pr.hit = Some((rp.to_vec(), cp.to_vec()));
|
||||||
if let Some(cells) = pr.cells.as_mut() {
|
|
||||||
cells.insert((r, l + j));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten());
|
g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! `TableStyleLight1…21 / Medium1…28 / Dark1…11` names so files round-trip).
|
//! `TableStyleLight1…21 / Medium1…28 / Dark1…11` names so files round-trip).
|
||||||
|
|
||||||
use gridcraft_core::{CellRef, RangeRef};
|
use gridcraft_core::{CellRef, RangeRef};
|
||||||
use gridcraft_model::{BorderLine, BorderStyle, Cell, Color, Fill, Formula, PatternType, Style, Table, Workbook};
|
use gridcraft_model::*;
|
||||||
|
|
||||||
/// (family, number) from a style name, e.g. `TableStyleMedium2` → ("Medium", 2).
|
/// (family, number) from a style name, e.g. `TableStyleMedium2` → ("Medium", 2).
|
||||||
pub fn parse_style_name(name: &str) -> (&'static str, u32) {
|
pub fn parse_style_name(name: &str) -> (&'static str, u32) {
|
||||||
|
|||||||
@@ -941,86 +941,6 @@ fn fill_without_formatting_extends_the_series_and_keeps_the_target_formats() {
|
|||||||
assert_eq!([v(&s, "B3"), v(&s, "B4")], [Value::from("Product 2"), Value::from("On backorder")]);
|
assert_eq!([v(&s, "B3"), v(&s, "B4")], [Value::from("Product 2"), Value::from("On backorder")]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fill_days_weekdays_months_and_years_step_a_date_by_that_unit() {
|
|
||||||
let mut s = s();
|
|
||||||
let serials = |s: &Session, cells: &[&str]| cells.iter().map(|c| v(s, c)).collect::<Vec<_>>();
|
|
||||||
let n = |xs: &[f64]| xs.iter().map(|x| Value::Number(*x)).collect::<Vec<_>>();
|
|
||||||
// Friday 1/30/2026 is serial 46052.
|
|
||||||
s.execute("cell.set", json!({"cell": "A1", "input": "1/30/2026"})).unwrap();
|
|
||||||
assert_eq!(v(&s, "A1"), Value::Number(46052.0), "setup: a date");
|
|
||||||
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "days"})).unwrap();
|
|
||||||
assert_eq!(serials(&s, &["A2", "A3", "A4"]), n(&[46053.0, 46054.0, 46055.0]));
|
|
||||||
// Fill Weekdays skips Saturday 1/31 and Sunday 2/1.
|
|
||||||
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "weekdays"})).unwrap();
|
|
||||||
assert_eq!(serials(&s, &["A2", "A3", "A4"]), n(&[46055.0, 46056.0, 46057.0]));
|
|
||||||
// From Saturday 1/31, the first weekday is Monday 2/2.
|
|
||||||
s.execute("cell.set", json!({"cell": "B1", "input": "1/31/2026"})).unwrap();
|
|
||||||
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B3", "mode": "weekdays"})).unwrap();
|
|
||||||
assert_eq!(serials(&s, &["B2", "B3"]), n(&[46055.0, 46056.0]));
|
|
||||||
// Fill Months keeps the day, clamped to the month's last day: Feb 28, Mar 31, Apr 30.
|
|
||||||
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B4", "mode": "months"})).unwrap();
|
|
||||||
assert_eq!(serials(&s, &["B2", "B3", "B4"]), n(&[46081.0, 46112.0, 46142.0]));
|
|
||||||
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B3", "mode": "years"})).unwrap();
|
|
||||||
assert_eq!(serials(&s, &["B2", "B3"]), n(&[46418.0, 46783.0]));
|
|
||||||
// Two dates step by their difference in the unit: 1/15 and 3/15 by two months.
|
|
||||||
s.execute("cell.set", json!({"cell": "C1", "input": "1/15/2026"})).unwrap();
|
|
||||||
s.execute("cell.set", json!({"cell": "C2", "input": "3/15/2026"})).unwrap();
|
|
||||||
s.execute("edit.autoFill", json!({"source": "C1:C2", "target": "C1:C4", "mode": "months"})).unwrap();
|
|
||||||
assert_eq!(serials(&s, &["C3", "C4"]), n(&[46157.0, 46218.0]));
|
|
||||||
// Monday 2/2 and Wednesday 2/4 step by two weekdays: Friday 2/6, Tuesday 2/10.
|
|
||||||
s.execute("cell.set", json!({"cell": "D1", "input": "2/2/2026"})).unwrap();
|
|
||||||
s.execute("cell.set", json!({"cell": "D2", "input": "2/4/2026"})).unwrap();
|
|
||||||
s.execute("edit.autoFill", json!({"source": "D1:D2", "target": "D1:D4", "mode": "weekdays"})).unwrap();
|
|
||||||
assert_eq!(serials(&s, &["D3", "D4"]), n(&[46059.0, 46063.0]));
|
|
||||||
// Upward from Monday 2/2: Friday 1/30, Thursday 1/29.
|
|
||||||
s.execute("cell.set", json!({"cell": "E5", "input": "2/2/2026"})).unwrap();
|
|
||||||
s.execute("edit.autoFill", json!({"source": "E5", "target": "E3:E5", "mode": "weekdays"})).unwrap();
|
|
||||||
assert_eq!(serials(&s, &["E4", "E3"]), n(&[46052.0, 46051.0]));
|
|
||||||
// A number that isn't a date fills as a plain drag does: a lone number copies.
|
|
||||||
s.execute("cell.set", json!({"cell": "F1", "input": "7"})).unwrap();
|
|
||||||
s.execute("edit.autoFill", json!({"source": "F1", "target": "F1:F2", "mode": "days"})).unwrap();
|
|
||||||
assert_eq!(v(&s, "F2"), Value::Number(7.0));
|
|
||||||
// An upper-case date format is a date: 1/31/2026 as YYYY-MM-DD fills by months.
|
|
||||||
s.execute("cell.set", json!({"cell": "G1", "input": "1/31/2026"})).unwrap();
|
|
||||||
s.execute("home.numberFormat", json!({"range": "G1", "code": "YYYY-MM-DD"})).unwrap();
|
|
||||||
s.execute("edit.autoFill", json!({"source": "G1", "target": "G1:G2", "mode": "months"})).unwrap();
|
|
||||||
assert_eq!(v(&s, "G2"), Value::Number(46081.0));
|
|
||||||
// A date with a time keeps the time: Fill Months and Fill Weekdays, a plain drag of two dates
|
|
||||||
// a month apart, and the Series dialog by months.
|
|
||||||
s.execute("cell.set", json!({"cell": "H1", "input": "1/31/2026 12:00"})).unwrap();
|
|
||||||
assert_eq!(v(&s, "H1"), Value::Number(46053.5), "setup: a date and time");
|
|
||||||
s.execute("edit.autoFill", json!({"source": "H1", "target": "H1:H2", "mode": "months"})).unwrap();
|
|
||||||
assert_eq!(v(&s, "H2"), Value::Number(46081.5));
|
|
||||||
s.execute("edit.autoFill", json!({"source": "H1", "target": "H1:H2", "mode": "weekdays"})).unwrap();
|
|
||||||
assert_eq!(v(&s, "H2"), Value::Number(46055.5));
|
|
||||||
s.execute("cell.set", json!({"cell": "I1", "input": "1/15/2026 6:00"})).unwrap();
|
|
||||||
s.execute("cell.set", json!({"cell": "I2", "input": "2/15/2026 6:00"})).unwrap();
|
|
||||||
s.execute("edit.autoFill", json!({"source": "I1:I2", "target": "I1:I3"})).unwrap();
|
|
||||||
assert_eq!(v(&s, "I3"), Value::Number(46096.25));
|
|
||||||
s.execute("cell.set", json!({"cell": "J1", "input": "1/31/2026 12:00"})).unwrap();
|
|
||||||
s.execute("edit.fillSeries", json!({"range": "J1:J2", "type": "date", "dateUnit": "month"})).unwrap();
|
|
||||||
assert_eq!(v(&s, "J2"), Value::Number(46081.5));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn only_a_number_in_a_date_format_offers_the_date_fills() {
|
|
||||||
let mut s = s();
|
|
||||||
let dates = |s: &Session, a: &str| crate::fill::starts_with_date(&s.doc().unwrap().wb, 0, gridcraft_core::RangeRef::parse(a).unwrap());
|
|
||||||
s.execute("cell.set", json!({"cell": "A1", "input": "1/31/2026"})).unwrap();
|
|
||||||
s.execute("cell.set", json!({"cell": "A2", "input": "7"})).unwrap();
|
|
||||||
s.execute("home.numberFormat", json!({"range": "A2", "code": "0 \"days\""})).unwrap();
|
|
||||||
s.execute("cell.set", json!({"cell": "A3", "input": "46053"})).unwrap();
|
|
||||||
s.execute("home.numberFormat", json!({"range": "A3", "code": "YYYY-MM-DD"})).unwrap();
|
|
||||||
s.execute("cell.set", json!({"cell": "A4", "input": "=A1"})).unwrap();
|
|
||||||
s.execute("cell.set", json!({"cell": "A5", "input": "13:30"})).unwrap();
|
|
||||||
assert!(dates(&s, "A1") && dates(&s, "A3"), "m/d/yyyy and YYYY-MM-DD are dates");
|
|
||||||
assert!(!dates(&s, "A2"), "quoted text in a number format is not a date field");
|
|
||||||
assert!(!dates(&s, "A4"), "a formula is not offered");
|
|
||||||
assert!(!dates(&s, "A5"), "a time is not a date");
|
|
||||||
assert!(!dates(&s, "A6"), "an empty cell is not a date");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn insert_delete_cells_adjust() {
|
fn insert_delete_cells_adjust() {
|
||||||
let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone();
|
let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone();
|
||||||
@@ -2864,42 +2784,6 @@ fn calculation_options_enable_iterative_calculation() {
|
|||||||
near(&s, "A1", 10.0);
|
near(&s, "A1", 10.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn error_checking_quotes_sheet_names_in_circular_references() {
|
|
||||||
let mut s = s();
|
|
||||||
s.execute("home.insertSheet", json!({"name": "Bob's Q1"})).unwrap();
|
|
||||||
s.execute("cell.set", json!({"cell": "B2", "input": "=B2+1"})).unwrap();
|
|
||||||
let r = s.execute("formulas.errorChecking", json!({})).unwrap();
|
|
||||||
assert_eq!(r["circular"], json!(["'Bob''s Q1'!B2"]));
|
|
||||||
// The reference reads back in a formula and in Go To.
|
|
||||||
let reference = r["circular"][0].as_str().unwrap().to_string();
|
|
||||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
|
||||||
s.execute("cell.set", json!({"cell": "A1", "input": format!("=ROW({reference})")})).unwrap();
|
|
||||||
assert_eq!(v(&s, "A1"), Value::Number(2.0));
|
|
||||||
s.execute("edit.goTo", json!({"reference": reference})).unwrap();
|
|
||||||
let d = s.doc().unwrap();
|
|
||||||
assert_eq!((d.wb.active_sheet, d.selection.active.a1()), (1, "B2".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Undo and redo put cells back without calculating them; the circular references listed are
|
|
||||||
/// those of the restored cells.
|
|
||||||
#[test]
|
|
||||||
fn undo_and_redo_list_the_circular_references_of_the_restored_cells() {
|
|
||||||
let mut s = s();
|
|
||||||
let listed = |s: &Session| s.doc().unwrap().calc.circular.iter().map(|(si, c)| (*si, c.a1())).collect::<Vec<_>>();
|
|
||||||
s.execute("cell.set", json!({"cell": "A5", "input": "=A5+1"})).unwrap();
|
|
||||||
assert_eq!(listed(&s), vec![(0, "A5".to_string())]);
|
|
||||||
s.execute("home.insertRows", json!({"rows": "2"})).unwrap();
|
|
||||||
assert_eq!(listed(&s), vec![(0, "A6".to_string())]);
|
|
||||||
s.execute("edit.undo", json!({})).unwrap();
|
|
||||||
assert_eq!(listed(&s), vec![(0, "A5".to_string())]);
|
|
||||||
s.execute("edit.redo", json!({})).unwrap();
|
|
||||||
assert_eq!(listed(&s), vec![(0, "A6".to_string())]);
|
|
||||||
// Undoing the loop itself lists none.
|
|
||||||
s.execute("edit.undo", json!({"steps": 2})).unwrap();
|
|
||||||
assert!(listed(&s).is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn chart_data(s: &mut Session) {
|
fn chart_data(s: &mut Session) {
|
||||||
s.execute(
|
s.execute(
|
||||||
"range.setValues",
|
"range.setValues",
|
||||||
@@ -4720,60 +4604,4 @@ fn html_export_without_pictures_or_charts_has_no_fixed_geometry() {
|
|||||||
let html = crate::io::to_html(&s.doc().unwrap().wb, 0).unwrap();
|
let html = crate::io::to_html(&s.doc().unwrap().wb, 0).unwrap();
|
||||||
assert!(!html.contains("<colgroup>") && !html.contains("table-layout") && !html.contains("<svg") && !html.contains("<img"), "{html}");
|
assert!(!html.contains("<colgroup>") && !html.contains("table-layout") && !html.contains("<svg") && !html.contains("<img"), "{html}");
|
||||||
}
|
}
|
||||||
#[test]
|
|
||||||
fn format_cells_permission_on_a_protected_sheet_cannot_unlock_cells() {
|
|
||||||
// Formatting may be allowed on a protected sheet; the permission can't serve as a key to
|
|
||||||
// content edits, so the format cells may not drop a cell's `locked` flag.
|
|
||||||
let mut s = s();
|
|
||||||
s.execute("cell.set", json!({"cell": "B2", "input": "hi"})).unwrap();
|
|
||||||
s.execute("review.protectSheet", json!({"formatCells": true})).unwrap();
|
|
||||||
s.execute("home.fontName", json!({"name": "Arial"})).unwrap();
|
|
||||||
let err = s.execute("home.lockCell", json!({"range": "B2", "on": false})).expect_err("unlock through formatting");
|
|
||||||
assert!(err.to_string().contains("protected"), "{err}");
|
|
||||||
assert!(s.execute("cell.set", json!({"cell": "B2", "input": "x"})).is_err(), "B2 stays locked");
|
|
||||||
// The same calls run once the sheet is unprotected.
|
|
||||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
|
||||||
s.execute("home.lockCell", json!({"range": "B2", "on": false})).unwrap();
|
|
||||||
s.execute("cell.set", json!({"cell": "B2", "input": "x"})).unwrap();
|
|
||||||
assert_eq!(v(&s, "B2"), Value::text("x"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn move_columns_checks_the_protection_of_the_named_sheet() {
|
|
||||||
// The move's protection checks must authorize the sheet it modifies, not whichever sheet
|
|
||||||
// happens to be active while the command names another 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();
|
|
||||||
let err = s.execute("sheet.moveColumns", json!({"sheet": "P", "cols": "B:C", "before": 4})).expect_err("moving columns on a protected sheet");
|
|
||||||
assert!(err.to_string().contains("protected"), "{err}");
|
|
||||||
let pv: Vec<Value> = {
|
|
||||||
let d = s.doc().unwrap();
|
|
||||||
let sh = &d.wb.sheets[d.wb.sheet_index("P").unwrap()];
|
|
||||||
(0..4).map(|c| sh.value(CellRef::new(0, c as u32))).collect()
|
|
||||||
};
|
|
||||||
assert_eq!(pv, vec![Value::number(1.0), Value::number(2.0), Value::number(3.0), Value::number(4.0)]);
|
|
||||||
// The active, unprotected sheet moves fine.
|
|
||||||
s.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D"]]})).unwrap();
|
|
||||||
s.execute("sheet.moveColumns", json!({"cols": "B:B", "before": 4})).unwrap();
|
|
||||||
assert_eq!(v(&s, "A1"), Value::text("A"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_script_cannot_switch_windows() {
|
|
||||||
// Stored scripts are scoped to their workbook: a step that activates another window would
|
|
||||||
// point every later step at another open document, so it is refused before any step runs.
|
|
||||||
let mut s = s();
|
|
||||||
s.execute("automate.newScript", json!({"name": "t", "commands": [{"command": "window.activate", "params": {"index": 0}}]})).unwrap();
|
|
||||||
s.execute("automate.trustScripts", json!({"trusted": true})).unwrap();
|
|
||||||
let err = s.execute("automate.runScript", json!({"name": "t"})).expect_err("script switching windows");
|
|
||||||
assert!(err.to_string().contains("window.activate"), "{err}");
|
|
||||||
// A benign script runs.
|
|
||||||
s.execute("automate.newScript", json!({"name": "ok", "commands": [{"command": "cell.set", "params": {"cell": "A1", "input": "=1+1"}}]})).unwrap();
|
|
||||||
s.execute("automate.runScript", json!({"name": "ok"})).unwrap();
|
|
||||||
assert_eq!(v(&s, "A1"), Value::number(2.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
mod seams;
|
mod seams;
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
//! The 100% stacked area chart: `insert.chart` and `chart.set` take type `area` with subtype
|
|
||||||
//! `stacked100`.
|
|
||||||
|
|
||||||
use gridcraft_engine::Session;
|
|
||||||
use gridcraft_model::ChartKind;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
fn session() -> Session {
|
|
||||||
let mut s = Session::new();
|
|
||||||
s.new_workbook();
|
|
||||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", "10", "30"], ["Q2", "30", "10"]]})).unwrap();
|
|
||||||
s
|
|
||||||
}
|
|
||||||
|
|
||||||
fn kinds(s: &Session) -> Vec<ChartKind> {
|
|
||||||
s.doc().unwrap().wb.sheet(0).unwrap().charts.iter().map(|c| c.kind).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn insert_chart_makes_a_hundred_percent_stacked_area() {
|
|
||||||
let mut s = session();
|
|
||||||
s.execute("insert.chart", json!({"range": "A1:C3", "type": "area", "subtype": "stacked100"})).unwrap();
|
|
||||||
assert_eq!(kinds(&s), [ChartKind::AreaStacked100]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn chart_set_changes_a_chart_to_a_hundred_percent_stacked_area_and_back() {
|
|
||||||
let mut s = session();
|
|
||||||
s.execute("insert.chart", json!({"range": "A1:C3", "type": "area", "subtype": "stacked"})).unwrap();
|
|
||||||
assert_eq!(kinds(&s), [ChartKind::AreaStacked]);
|
|
||||||
s.execute("chart.set", json!({"type": "area", "subtype": "stacked100"})).unwrap();
|
|
||||||
assert_eq!(kinds(&s), [ChartKind::AreaStacked100]);
|
|
||||||
s.execute("edit.undo", json!({})).unwrap();
|
|
||||||
assert_eq!(kinds(&s), [ChartKind::AreaStacked]);
|
|
||||||
}
|
|
||||||
@@ -232,7 +232,7 @@ fn guarded<T>(step: usize, kind: &'static str, f: impl FnOnce() -> Result<T, Str
|
|||||||
Ok(Ok(v)) => Ok(v),
|
Ok(Ok(v)) => Ok(v),
|
||||||
Ok(Err(e)) => Err(fail(step, kind, e)),
|
Ok(Err(e)) => Err(fail(step, kind, e)),
|
||||||
Err(p) => {
|
Err(p) => {
|
||||||
let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||||
Err(fail(step, kind, format!("panicked: {msg}")))
|
Err(fail(step, kind, format!("panicked: {msg}")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
mod autofit_filter_buttons;
|
mod autofit_filter_buttons;
|
||||||
mod cf_formula_validate;
|
mod cf_formula_validate;
|
||||||
mod chart_area_stacked100;
|
|
||||||
mod chart_workbook_names;
|
mod chart_workbook_names;
|
||||||
mod command_sequence_fuzz;
|
mod command_sequence_fuzz;
|
||||||
mod cross_sheet_cut;
|
mod cross_sheet_cut;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
use gridcraft_core::{CellError, MAX_COLS, MAX_ROWS, RangeRef};
|
use gridcraft_core::{CellError, MAX_COLS, MAX_ROWS, RangeRef};
|
||||||
|
|
||||||
use crate::ast::{Anchor, Expr, RefKind, Reference, SheetSel, clamp_anchor};
|
use crate::ast::*;
|
||||||
|
|
||||||
/// Copy/fill: relative parts move by `(dr, dc)`. References pushed off the sheet become `#REF!`.
|
/// Copy/fill: relative parts move by `(dr, dc)`. References pushed off the sheet become `#REF!`.
|
||||||
pub fn shift_relative(e: Expr, dr: i64, dc: i64) -> Expr {
|
pub fn shift_relative(e: Expr, dr: i64, dc: i64) -> Expr {
|
||||||
|
|||||||
+12
-14
@@ -23,20 +23,18 @@ pub fn same_name(a: &str, b: &str) -> bool {
|
|||||||
if a.is_ascii() && b.is_ascii() {
|
if a.is_ascii() && b.is_ascii() {
|
||||||
return a.eq_ignore_ascii_case(b);
|
return a.eq_ignore_ascii_case(b);
|
||||||
}
|
}
|
||||||
a == b || fold_name(a) == fold_name(b)
|
let fold = |s: &str| -> String {
|
||||||
}
|
s.chars()
|
||||||
|
.flat_map(char::to_lowercase)
|
||||||
/// The case-folded form of a name: two names are [`same_name`] when their folded forms are equal.
|
.flat_map(|c| match c {
|
||||||
pub fn fold_name(s: &str) -> String {
|
'ß' => ['s', 's'].into_iter().take(2),
|
||||||
s.chars()
|
'ſ' => ['s', 's'].into_iter().take(1),
|
||||||
.flat_map(char::to_lowercase)
|
'ς' => ['σ', 'σ'].into_iter().take(1),
|
||||||
.flat_map(|c| match c {
|
c => [c, c].into_iter().take(1),
|
||||||
'ß' => ['s', 's'].into_iter().take(2),
|
})
|
||||||
'ſ' => ['s', 's'].into_iter().take(1),
|
.collect()
|
||||||
'ς' => ['σ', 'σ'].into_iter().take(1),
|
};
|
||||||
c => [c, c].into_iter().take(1),
|
a == b || fold(a) == fold(b)
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
|||||||
use gridcraft_core::addr::{letters_to_col, parse_a1_prefix};
|
use gridcraft_core::addr::{letters_to_col, parse_a1_prefix};
|
||||||
use gridcraft_core::{CellError, MAX_ROWS};
|
use gridcraft_core::{CellError, MAX_ROWS};
|
||||||
|
|
||||||
use crate::ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, TrimMode, UnOp};
|
use crate::ast::*;
|
||||||
use crate::lexer::{Tok, Token, tokenize_locale};
|
use crate::lexer::{Tok, Token, tokenize_locale};
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
|
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::fmt::Write;
|
|||||||
|
|
||||||
use gridcraft_core::{CellRef, col_to_letters, number_to_text};
|
use gridcraft_core::{CellRef, col_to_letters, number_to_text};
|
||||||
|
|
||||||
use crate::ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp};
|
use crate::ast::*;
|
||||||
|
|
||||||
pub use gridcraft_core::{quote_sheet, sheet_needs_quotes as needs_quotes};
|
pub use gridcraft_core::{quote_sheet, sheet_needs_quotes as needs_quotes};
|
||||||
|
|
||||||
|
|||||||
@@ -123,60 +123,6 @@ impl Criterion {
|
|||||||
Criterion { op, operand, bare_empty: false }
|
Criterion { op, operand, bare_empty: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn matches_blank(&self, v: &Value) -> bool {
|
|
||||||
let blank = matches!(v, Value::Empty);
|
|
||||||
match self.op {
|
|
||||||
Op::Eq => blank,
|
|
||||||
Op::Ne => !blank,
|
|
||||||
// "<" / ">" with nothing: Excel compares against "" for text cells, but an
|
|
||||||
// empty string never matches ("<=" and ">=" count it in neither).
|
|
||||||
_ => match v {
|
|
||||||
Value::Text(t) if !t.is_empty() => cmp_op(self.op, compare_text(t, "")),
|
|
||||||
_ => false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn matches_number(&self, v: &Value, n: f64) -> bool {
|
|
||||||
let cell = match v {
|
|
||||||
Value::Number(x) => Some(*x),
|
|
||||||
// Numeric text matches only for equality (COUNTIF("1") counts text "1").
|
|
||||||
Value::Text(t) if matches!(self.op, Op::Eq | Op::Ne) => parse_number_text(t),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
match cell {
|
|
||||||
Some(x) => cmp_op(self.op, compare_numbers(x, n)),
|
|
||||||
None => self.op == Op::Ne,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn matches_text(&self, v: &Value, pat: &str) -> bool {
|
|
||||||
match self.op {
|
|
||||||
Op::Eq | Op::Ne => {
|
|
||||||
// Blank cells hold no text: `"*"` skips them and `"<>*"` counts them.
|
|
||||||
let cell_text = match v {
|
|
||||||
Value::Text(t) => Some(t.to_string()),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
let m = cell_text.is_some_and(|t| wildcard_match(pat, &t));
|
|
||||||
if self.op == Op::Eq { m } else { !m }
|
|
||||||
}
|
|
||||||
_ => match v {
|
|
||||||
Value::Text(t) => cmp_op(self.op, compare_text(t, pat)),
|
|
||||||
_ => false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn matches_cut_text(&self, v: &Value, kept: &str) -> bool {
|
|
||||||
match self.op {
|
|
||||||
Op::Eq => false,
|
|
||||||
Op::Ne => true,
|
|
||||||
Op::Lt | Op::Le => matches!(v, Value::Text(t) if compare_text(t, kept) != Ordering::Greater),
|
|
||||||
Op::Gt | Op::Ge => matches!(v, Value::Text(t) if compare_text(t, kept) == Ordering::Greater),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a cell value satisfies the criterion.
|
/// Whether a cell value satisfies the criterion.
|
||||||
pub(crate) fn matches(&self, v: &Value) -> bool {
|
pub(crate) fn matches(&self, v: &Value) -> bool {
|
||||||
let v = match v {
|
let v = match v {
|
||||||
@@ -187,8 +133,31 @@ impl Criterion {
|
|||||||
return matches!(&v, Value::Empty) || matches!(&v, Value::Text(t) if t.is_empty());
|
return matches!(&v, Value::Empty) || matches!(&v, Value::Text(t) if t.is_empty());
|
||||||
}
|
}
|
||||||
match &self.operand {
|
match &self.operand {
|
||||||
Operand::Blank => self.matches_blank(&v),
|
Operand::Blank => {
|
||||||
Operand::Number(n) => self.matches_number(&v, *n),
|
let blank = matches!(v, Value::Empty);
|
||||||
|
match self.op {
|
||||||
|
Op::Eq => blank,
|
||||||
|
Op::Ne => !blank,
|
||||||
|
// "<" / ">" with nothing: Excel compares against "" for text cells, but an
|
||||||
|
// empty string never matches ("<=" and ">=" count it in neither).
|
||||||
|
_ => match &v {
|
||||||
|
Value::Text(t) if !t.is_empty() => cmp_op(self.op, compare_text(t, "")),
|
||||||
|
_ => false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Operand::Number(n) => {
|
||||||
|
let cell = match &v {
|
||||||
|
Value::Number(x) => Some(*x),
|
||||||
|
// Numeric text matches only for equality (COUNTIF("1") counts text "1").
|
||||||
|
Value::Text(t) if matches!(self.op, Op::Eq | Op::Ne) => parse_number_text(t),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
match cell {
|
||||||
|
Some(x) => cmp_op(self.op, compare_numbers(x, *n)),
|
||||||
|
None => self.op == Op::Ne,
|
||||||
|
}
|
||||||
|
}
|
||||||
Operand::Bool(b) => match &v {
|
Operand::Bool(b) => match &v {
|
||||||
Value::Bool(x) => cmp_op(self.op, x.cmp(b)),
|
Value::Bool(x) => cmp_op(self.op, x.cmp(b)),
|
||||||
_ => self.op == Op::Ne,
|
_ => self.op == Op::Ne,
|
||||||
@@ -197,8 +166,27 @@ impl Criterion {
|
|||||||
Value::Error(x) => cmp_op(self.op, x.code().cmp(&e.code())),
|
Value::Error(x) => cmp_op(self.op, x.code().cmp(&e.code())),
|
||||||
_ => self.op == Op::Ne,
|
_ => self.op == Op::Ne,
|
||||||
},
|
},
|
||||||
Operand::Text(pat) => self.matches_text(&v, pat),
|
Operand::Text(pat) => match self.op {
|
||||||
Operand::CutText(kept) => self.matches_cut_text(&v, kept),
|
Op::Eq | Op::Ne => {
|
||||||
|
// Blank cells hold no text: `"*"` skips them and `"<>*"` counts them.
|
||||||
|
let cell_text = match &v {
|
||||||
|
Value::Text(t) => Some(t.to_string()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let m = cell_text.is_some_and(|t| wildcard_match(pat, &t));
|
||||||
|
if self.op == Op::Eq { m } else { !m }
|
||||||
|
}
|
||||||
|
_ => match &v {
|
||||||
|
Value::Text(t) => cmp_op(self.op, compare_text(t, pat)),
|
||||||
|
_ => false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Operand::CutText(kept) => match self.op {
|
||||||
|
Op::Eq => false,
|
||||||
|
Op::Ne => true,
|
||||||
|
Op::Lt | Op::Le => matches!(&v, Value::Text(t) if compare_text(t, kept) != Ordering::Greater),
|
||||||
|
Op::Gt | Op::Ge => matches!(&v, Value::Text(t) if compare_text(t, kept) == Ordering::Greater),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,27 +264,15 @@ pub(crate) fn has_wildcards(pattern: &str) -> bool {
|
|||||||
pub(crate) fn wildcard_match(pattern: &str, text: &str) -> bool {
|
pub(crate) fn wildcard_match(pattern: &str, text: &str) -> bool {
|
||||||
let p = tokenize(pattern);
|
let p = tokenize(pattern);
|
||||||
let t = lower_chars(text);
|
let t = lower_chars(text);
|
||||||
let mut budget = MAX_STEPS;
|
match_from(&p, &t, true).is_some()
|
||||||
match_from(&p, &t, true, &mut budget).is_some()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Steps one `match_from` call may take. An honest match against Excel's 32,767-character
|
|
||||||
/// cell ceiling costs a few hundred thousand; a hostile one (star churn) is capped instead
|
|
||||||
/// of quadratic — a call that spends more gives up and reports no match, quiet degradation
|
|
||||||
/// like the other work caps on far-past-realistic input.
|
|
||||||
const MAX_STEPS: u64 = 1 << 26;
|
|
||||||
|
|
||||||
/// Greedy-free matcher: returns the matched length of `t` when `p` matches a prefix of `t`
|
/// Greedy-free matcher: returns the matched length of `t` when `p` matches a prefix of `t`
|
||||||
/// (or all of `t` when `whole`). Iterative with backtracking on the last star (linear-ish,
|
/// (or all of `t` when `whole`). Iterative with backtracking on the last star (linear-ish).
|
||||||
/// quadratic at worst). `(budget, whole)`: the budget is spent one per loop step; a call that
|
fn match_from(p: &[Tok], t: &[char], whole: bool) -> Option<usize> {
|
||||||
/// would spend more than it has gives up and reports no match — quiet degradation, as with the
|
|
||||||
/// other work caps on far-past-realistic input (an honest match against Excel's 32,767-char
|
|
||||||
/// cell ceiling costs a few hundred thousand steps).
|
|
||||||
fn match_from(p: &[Tok], t: &[char], whole: bool, budget: &mut u64) -> Option<usize> {
|
|
||||||
let (mut pi, mut ti) = (0usize, 0usize);
|
let (mut pi, mut ti) = (0usize, 0usize);
|
||||||
let mut star: Option<(usize, usize)> = None;
|
let mut star: Option<(usize, usize)> = None;
|
||||||
loop {
|
loop {
|
||||||
*budget = budget.checked_sub(1)?;
|
|
||||||
if pi == p.len() && (!whole || ti == t.len()) {
|
if pi == p.len() && (!whole || ti == t.len()) {
|
||||||
return Some(ti);
|
return Some(ti);
|
||||||
}
|
}
|
||||||
@@ -331,32 +307,10 @@ fn match_from(p: &[Tok], t: &[char], whole: bool, budget: &mut u64) -> Option<us
|
|||||||
pub(crate) fn wildcard_find(pattern: &str, text: &str, start: usize) -> Option<usize> {
|
pub(crate) fn wildcard_find(pattern: &str, text: &str, start: usize) -> Option<usize> {
|
||||||
let p = tokenize(pattern);
|
let p = tokenize(pattern);
|
||||||
let t = lower_chars(text);
|
let t = lower_chars(text);
|
||||||
// A leading run of stars never matters for a prefix match (it consumes nothing), so
|
|
||||||
// dropping it lets the anchor below skip linearly through the text (#2382).
|
|
||||||
let skip = p.iter().take_while(|tk| matches!(tk, Tok::Star)).count();
|
|
||||||
let p = &p[skip.min(p.len())..];
|
|
||||||
if p.is_empty() {
|
if p.is_empty() {
|
||||||
return if start <= t.len() { Some(start) } else { None };
|
return if start <= t.len() { Some(start) } else { None };
|
||||||
}
|
}
|
||||||
// The first token that consumes a character anchors the scan: a literal skips straight
|
(start..t.len()).find(|&i| t.get(i..).is_some_and(|rest| match_from(&p, rest, false).is_some()))
|
||||||
// to its occurrences, and after leading `?`s each attempt only pays for the `?` run and
|
|
||||||
// one suffix try. Every attempt shares one budget, so the total work stays flat.
|
|
||||||
let anys = p.iter().take_while(|tk| matches!(tk, Tok::Any)).count();
|
|
||||||
let anchor = p.get(anys);
|
|
||||||
let mut budget = MAX_STEPS;
|
|
||||||
let mut i = start;
|
|
||||||
while i < t.len() {
|
|
||||||
let hit = match (anchor, t.get(i + anys)) {
|
|
||||||
(Some(Tok::Char(c)), Some(rest)) => *rest == *c && match_from(p, &t[i..], false, &mut budget).is_some(),
|
|
||||||
(Some(Tok::Char(_)), None) => break, // the anchor char no longer exists ahead
|
|
||||||
_ => match_from(p, &t[i..], false, &mut budget).is_some(),
|
|
||||||
};
|
|
||||||
if hit {
|
|
||||||
return Some(i);
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exact-or-wildcard equality used by MATCH/VLOOKUP/XLOOKUP exact modes: text with wildcards
|
/// Exact-or-wildcard equality used by MATCH/VLOOKUP/XLOOKUP exact modes: text with wildcards
|
||||||
|
|||||||
@@ -742,7 +742,7 @@ fn im_binary(a: &[Arg], f: fn(Cx, Cx) -> R<Cx>) -> R<Value> {
|
|||||||
fmt_complex(f(x, y)?, s.unwrap_or('i'))
|
fmt_complex(f(x, y)?, s.unwrap_or('i'))
|
||||||
}
|
}
|
||||||
fn imdiv(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
fn imdiv(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||||
im_binary(a, Cx::div)
|
im_binary(a, |x, y| x.div(y))
|
||||||
}
|
}
|
||||||
fn imsub(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
fn imsub(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||||
im_binary(a, |x, y| Ok(x.sub(y)))
|
im_binary(a, |x, y| Ok(x.sub(y)))
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ fn aggregate(kind: Aggregate, xs: &mut [f64]) -> Option<f64> {
|
|||||||
Aggregate::Max => xs.iter().copied().reduce(f64::max),
|
Aggregate::Max => xs.iter().copied().reduce(f64::max),
|
||||||
Aggregate::Min => xs.iter().copied().reduce(f64::min),
|
Aggregate::Min => xs.iter().copied().reduce(f64::min),
|
||||||
Aggregate::Median => {
|
Aggregate::Median => {
|
||||||
xs.sort_by(f64::total_cmp);
|
xs.sort_by(|a, b| a.total_cmp(b));
|
||||||
let k = xs.len() / 2;
|
let k = xs.len() / 2;
|
||||||
let hi = xs.get(k).copied()?;
|
let hi = xs.get(k).copied()?;
|
||||||
if xs.len() % 2 == 1 { Some(hi) } else { Some((xs.get(k.checked_sub(1)?).copied()? + hi) / 2.0) }
|
if xs.len() % 2 == 1 { Some(hi) } else { Some((xs.get(k.checked_sub(1)?).copied()? + hi) / 2.0) }
|
||||||
@@ -415,7 +415,7 @@ impl Timeline {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let mut sorted = times.to_vec();
|
let mut sorted = times.to_vec();
|
||||||
sorted.sort_by(f64::total_cmp);
|
sorted.sort_by(|a, b| a.total_cmp(b));
|
||||||
sorted.dedup();
|
sorted.dedup();
|
||||||
let axis = detect_axis(&sorted, sys).ok()?;
|
let axis = detect_axis(&sorted, sys).ok()?;
|
||||||
let last = axis.position(*sorted.last()?)?.round();
|
let last = axis.position(*sorted.last()?)?.round();
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ impl FnSpec {
|
|||||||
pub(crate) const VAR: usize = usize::MAX;
|
pub(crate) const VAR: usize = usize::MAX;
|
||||||
|
|
||||||
fn registry() -> &'static (Vec<FnSpec>, HashMap<&'static str, usize>) {
|
fn registry() -> &'static (Vec<FnSpec>, HashMap<&'static str, usize>) {
|
||||||
static REG: OnceLock<(Vec<FnSpec>, HashMap<&str, usize>)> = OnceLock::new();
|
static REG: OnceLock<(Vec<FnSpec>, HashMap<&'static str, usize>)> = OnceLock::new();
|
||||||
REG.get_or_init(|| {
|
REG.get_or_init(|| {
|
||||||
let mut v: Vec<FnSpec> = Vec::new();
|
let mut v: Vec<FnSpec> = Vec::new();
|
||||||
for list in [
|
for list in [
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ fn var_p(x: &[f64]) -> R<f64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn sorted(mut v: Vec<f64>) -> Vec<f64> {
|
fn sorted(mut v: Vec<f64>) -> Vec<f64> {
|
||||||
v.sort_by(f64::total_cmp);
|
v.sort_by(|a, b| a.total_cmp(b));
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -523,7 +523,7 @@ fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool, from_end
|
|||||||
let mut j = t.len();
|
let mut j = t.len();
|
||||||
while j > 0 {
|
while j > 0 {
|
||||||
let best =
|
let best =
|
||||||
ds.iter().filter(|d| j.checked_sub(d.len()).and_then(|st| t.get(st..j)).is_some_and(|w| w == d.as_slice())).map(Vec::len).max();
|
ds.iter().filter(|d| j.checked_sub(d.len()).and_then(|st| t.get(st..j)).is_some_and(|w| w == d.as_slice())).map(|d| d.len()).max();
|
||||||
match best {
|
match best {
|
||||||
Some(l) => {
|
Some(l) => {
|
||||||
out.push((j - l, j));
|
out.push((j - l, j));
|
||||||
@@ -537,7 +537,7 @@ fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool, from_end
|
|||||||
}
|
}
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < t.len() {
|
while i < t.len() {
|
||||||
let best = ds.iter().filter(|d| t.get(i..i + d.len()).is_some_and(|w| w == d.as_slice())).map(Vec::len).max();
|
let best = ds.iter().filter(|d| t.get(i..i + d.len()).is_some_and(|w| w == d.as_slice())).map(|d| d.len()).max();
|
||||||
match best {
|
match best {
|
||||||
Some(l) => {
|
Some(l) => {
|
||||||
out.push((i, i + l));
|
out.push((i, i + l));
|
||||||
@@ -588,7 +588,7 @@ fn textafter(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn split_by(s: &[char], delims: &[Vec<char>], insensitive: bool) -> Vec<Vec<char>> {
|
fn split_by(s: &[char], delims: &[Vec<char>], insensitive: bool) -> Vec<Vec<char>> {
|
||||||
if delims.iter().all(Vec::is_empty) {
|
if delims.iter().all(|d| d.is_empty()) {
|
||||||
return vec![s.to_vec()];
|
return vec![s.to_vec()];
|
||||||
}
|
}
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
@@ -748,9 +748,9 @@ fn convert_replacement(r: &str) -> String {
|
|||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < c.len() {
|
while i < c.len() {
|
||||||
let ch = c[i];
|
let ch = c[i];
|
||||||
if ch == '$' && c.get(i + 1).is_some_and(char::is_ascii_digit) {
|
if ch == '$' && c.get(i + 1).is_some_and(|d| d.is_ascii_digit()) {
|
||||||
let mut j = i + 1;
|
let mut j = i + 1;
|
||||||
while c.get(j).is_some_and(char::is_ascii_digit) {
|
while c.get(j).is_some_and(|d| d.is_ascii_digit()) {
|
||||||
j += 1;
|
j += 1;
|
||||||
}
|
}
|
||||||
out.push_str("${");
|
out.push_str("${");
|
||||||
|
|||||||
@@ -410,7 +410,6 @@ pub enum ChartKind {
|
|||||||
Doughnut,
|
Doughnut,
|
||||||
Area,
|
Area,
|
||||||
AreaStacked,
|
AreaStacked,
|
||||||
AreaStacked100,
|
|
||||||
Scatter,
|
Scatter,
|
||||||
ScatterLines,
|
ScatterLines,
|
||||||
Bubble,
|
Bubble,
|
||||||
@@ -1045,9 +1044,6 @@ pub struct PivotTable {
|
|||||||
pub values: Vec<PivotValue>,
|
pub values: Vec<PivotValue>,
|
||||||
pub filters: Vec<PivotFilter>,
|
pub filters: Vec<PivotFilter>,
|
||||||
pub layout: PivotLayout,
|
pub layout: PivotLayout,
|
||||||
/// PivotTable Options › Data › Enable show details: Show Details lists a value cell's
|
|
||||||
/// source records only while this is on.
|
|
||||||
pub enable_show_details: bool,
|
|
||||||
/// Grand total for each row (the "Grand Total" column), like Excel's "On for Rows".
|
/// Grand total for each row (the "Grand Total" column), like Excel's "On for Rows".
|
||||||
pub grand_totals_rows: bool,
|
pub grand_totals_rows: bool,
|
||||||
/// Grand total for each column (the "Grand Total" row), like Excel's "On for Columns".
|
/// Grand total for each column (the "Grand Total" row), like Excel's "On for Columns".
|
||||||
@@ -1091,7 +1087,6 @@ impl Default for PivotTable {
|
|||||||
values: vec![],
|
values: vec![],
|
||||||
filters: vec![],
|
filters: vec![],
|
||||||
layout: PivotLayout::Compact,
|
layout: PivotLayout::Compact,
|
||||||
enable_show_details: true,
|
|
||||||
grand_totals_rows: true,
|
grand_totals_rows: true,
|
||||||
grand_totals_cols: true,
|
grand_totals_cols: true,
|
||||||
subtotals: true,
|
subtotals: true,
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ use gridcraft_core::{CellRef, RangeRef, Value};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::cell::{Cell, CellPicture};
|
use crate::cell::{Cell, CellPicture};
|
||||||
use crate::features::{
|
use crate::features::*;
|
||||||
AutoFilter, Chart, Comment, CondFormat, Hyperlink, Image, LineInfo, PivotTable, PrintSettings, Shape, SheetProtection, Slicer, Sparkline, Table,
|
|
||||||
Validation,
|
|
||||||
};
|
|
||||||
use crate::store::CellStore;
|
use crate::store::CellStore;
|
||||||
use crate::style::{Color, StyleId};
|
use crate::style::{Color, StyleId};
|
||||||
|
|
||||||
@@ -83,13 +80,6 @@ pub struct Sheet {
|
|||||||
/// outline itself stays.
|
/// outline itself stays.
|
||||||
#[serde(default = "yes")]
|
#[serde(default = "yes")]
|
||||||
pub show_outline_symbols: bool,
|
pub show_outline_symbols: bool,
|
||||||
/// Outline summary rows sit below their detail, and summary columns right of it (Data ▸
|
|
||||||
/// Outline ▸ Settings; off puts them above / left). Saved as `outlinePr summaryBelow` and
|
|
||||||
/// `summaryRight`.
|
|
||||||
#[serde(default = "yes")]
|
|
||||||
pub summary_below: bool,
|
|
||||||
#[serde(default = "yes")]
|
|
||||||
pub summary_right: bool,
|
|
||||||
pub right_to_left: bool,
|
pub right_to_left: bool,
|
||||||
pub zoom: u16,
|
pub zoom: u16,
|
||||||
pub cond_formats: Vec<CondFormat>,
|
pub cond_formats: Vec<CondFormat>,
|
||||||
@@ -159,8 +149,6 @@ impl Sheet {
|
|||||||
visibility: Visibility::Visible,
|
visibility: Visibility::Visible,
|
||||||
show_gridlines: true,
|
show_gridlines: true,
|
||||||
show_outline_symbols: true,
|
show_outline_symbols: true,
|
||||||
summary_below: true,
|
|
||||||
summary_right: true,
|
|
||||||
show_headings: true,
|
show_headings: true,
|
||||||
show_formulas: false,
|
show_formulas: false,
|
||||||
show_zeros: true,
|
show_zeros: true,
|
||||||
@@ -625,11 +613,8 @@ mod tests {
|
|||||||
|
|
||||||
// CellRef is a struct, so encode the sparse map as entries for JSON as well as other formats.
|
// CellRef is a struct, so encode the sparse map as entries for JSON as well as other formats.
|
||||||
mod picture_map {
|
mod picture_map {
|
||||||
use crate::cell::CellPicture;
|
use super::*;
|
||||||
use gridcraft_core::CellRef;
|
use serde::{Deserializer, Serializer};
|
||||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
pub fn serialize<S: Serializer>(map: &BTreeMap<CellRef, Arc<CellPicture>>, serializer: S) -> Result<S::Ok, S::Error> {
|
pub fn serialize<S: Serializer>(map: &BTreeMap<CellRef, Arc<CellPicture>>, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
map.iter().collect::<Vec<_>>().serialize(serializer)
|
map.iter().collect::<Vec<_>>().serialize(serializer)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -525,7 +525,7 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt
|
|||||||
let s = if k + 1 == count {
|
let s = if k + 1 == count {
|
||||||
dc.get(k..).map(|r| r.iter().collect::<String>()).unwrap_or_default()
|
dc.get(k..).map(|r| r.iter().collect::<String>()).unwrap_or_default()
|
||||||
} else {
|
} else {
|
||||||
dc.get(k).map(ToString::to_string).unwrap_or_default()
|
dc.get(k).map(|c| c.to_string()).unwrap_or_default()
|
||||||
};
|
};
|
||||||
let s = if s.is_empty() {
|
let s = if s.is_empty() {
|
||||||
match toks.get(ti) {
|
match toks.get(ti) {
|
||||||
|
|||||||
@@ -99,8 +99,8 @@ pub(crate) fn fold(c: char) -> Option<char> {
|
|||||||
'ž' => 'z',
|
'ž' => 'z',
|
||||||
'Œ' => 'W',
|
'Œ' => 'W',
|
||||||
'œ' => 'm',
|
'œ' => 'm',
|
||||||
'‘'..='‚' => '\'',
|
'‘' | '’' | '‚' => '\'',
|
||||||
'“'..='„' => '"',
|
'“' | '”' | '„' => '"',
|
||||||
'–' | '€' | '£' | '¥' | '¢' | '¤' => '0',
|
'–' | '€' | '£' | '¥' | '¢' | '¤' => '0',
|
||||||
'\u{a0}' => ' ',
|
'\u{a0}' => ' ',
|
||||||
'·' | '•' => '.',
|
'·' | '•' => '.',
|
||||||
|
|||||||
@@ -352,8 +352,8 @@ fn new_tiles(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
|
|||||||
/// File name and folder of a recent path, split as text.
|
/// File name and folder of a recent path, split as text.
|
||||||
fn split_path(path: &str) -> (String, String) {
|
fn split_path(path: &str) -> (String, String) {
|
||||||
let p = std::path::Path::new(path);
|
let p = std::path::Path::new(path);
|
||||||
let name = p.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(path).to_string();
|
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(path).to_string();
|
||||||
let folder = p.parent().and_then(std::path::Path::to_str).unwrap_or("").to_string();
|
let folder = p.parent().and_then(|f| f.to_str()).unwrap_or("").to_string();
|
||||||
(name, folder)
|
(name, folder)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,7 +888,7 @@ pub fn export_as(app: &mut SheetApp, fmt: &str) -> bool {
|
|||||||
_ => return false,
|
_ => return false,
|
||||||
};
|
};
|
||||||
let title = app.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into());
|
let title = app.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into());
|
||||||
let stem = std::path::Path::new(&title).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book1").to_string();
|
let stem = std::path::Path::new(&title).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
|
||||||
if let Some(pick) = &app.services.pick_save {
|
if let Some(pick) = &app.services.pick_save {
|
||||||
let Some(path) = pick(&format!("{stem}.{fmt}")) else { return false };
|
let Some(path) = pick(&format!("{stem}.{fmt}")) else { return false };
|
||||||
// The picker may suggest a workbook extension; the export writes the chosen format.
|
// The picker may suggest a workbook extension; the export writes the chosen format.
|
||||||
|
|||||||
@@ -130,12 +130,7 @@ pub(crate) fn paint(painter: &Painter, cell: Rect, picture: &Arc<CellPicture>) {
|
|||||||
painter.image(texture.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
painter.image(texture.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
crate::icons::paint_on_sheet(
|
crate::icons::paint(&painter, cell.shrink(2.0), crate::icons::Icon::Picture, Color32::from_gray(130));
|
||||||
&painter,
|
|
||||||
cell.shrink(2.0),
|
|
||||||
crate::icons::Icon::Picture,
|
|
||||||
crate::theme::Tokens::get(painter.ctx()).sheet_control_border,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use gridcraft_engine::model::{Anchor, ShapeKind, Sheet, Workbook};
|
|||||||
|
|
||||||
use crate::SheetApp;
|
use crate::SheetApp;
|
||||||
use crate::grid::Geo;
|
use crate::grid::Geo;
|
||||||
use crate::theme::{self, Tokens};
|
use crate::theme;
|
||||||
|
|
||||||
fn rgba(c: [u8; 4]) -> Color32 {
|
fn rgba(c: [u8; 4]) -> Color32 {
|
||||||
Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3])
|
Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3])
|
||||||
@@ -310,8 +310,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
|
|||||||
}
|
}
|
||||||
ShapeKind::Icon => {
|
ShapeKind::Icon => {
|
||||||
if let Some(icon) = crate::icons::from_name(&sp.text) {
|
if let Some(icon) = crate::icons::from_name(&sp.text) {
|
||||||
let t = Tokens::get(p.ctx());
|
crate::icons::paint(p, r, icon, if fill == Color32::TRANSPARENT { Color32::BLACK } else { fill });
|
||||||
crate::icons::paint_on_sheet(p, r, icon, if fill == Color32::TRANSPARENT { t.cell_text } else { fill });
|
|
||||||
}
|
}
|
||||||
label = false;
|
label = false;
|
||||||
}
|
}
|
||||||
@@ -342,7 +341,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
|
|||||||
if let Some(tex) = crate::floating_pictures::texture(p.ctx(), doc, im, r) {
|
if let Some(tex) = crate::floating_pictures::texture(p.ctx(), doc, im, r) {
|
||||||
p.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
p.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||||
} else {
|
} else {
|
||||||
p.rect_filled(r, 0.0, Tokens::get(p.ctx()).sheet_track);
|
p.rect_filled(r, 0.0, Color32::from_gray(230));
|
||||||
}
|
}
|
||||||
if app.selected_chart == Some(im.id) {
|
if app.selected_chart == Some(im.id) {
|
||||||
selection_frame(p, r);
|
selection_frame(p, r);
|
||||||
@@ -357,7 +356,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
|
|||||||
let start = p.add(egui::Shape::Noop);
|
let start = p.add(egui::Shape::Noop);
|
||||||
// Soft shadow + white card.
|
// Soft shadow + white card.
|
||||||
p.rect_filled(r.translate(vec2(0.0, 1.5)), 2.0, Color32::from_black_alpha(18));
|
p.rect_filled(r.translate(vec2(0.0, 1.5)), 2.0, Color32::from_black_alpha(18));
|
||||||
p.rect_filled(r, 0.0, Tokens::get(p.ctx()).grid_bg);
|
p.rect_filled(r, 0.0, Color32::WHITE);
|
||||||
let data = gridcraft_chart::resolve(wb, si, ch);
|
let data = gridcraft_chart::resolve(wb, si, ch);
|
||||||
let m = EguiMeasure { painter: p, z: geo.z };
|
let m = EguiMeasure { painter: p, z: geo.z };
|
||||||
let prims = gridcraft_chart::render(ch, &data, ch.anchor.width, ch.anchor.height, &m);
|
let prims = gridcraft_chart::render(ch, &data, ch.anchor.width, ch.anchor.height, &m);
|
||||||
@@ -374,10 +373,9 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
|
|||||||
&& rf.sheet_name().is_none_or(|n| n.eq_ignore_ascii_case(&sh.name))
|
&& rf.sheet_name().is_none_or(|n| n.eq_ignore_ascii_case(&sh.name))
|
||||||
{
|
{
|
||||||
let rr = geo.range_rect(sh, rf.range());
|
let rr = geo.range_rect(sh, rf.range());
|
||||||
let color = Tokens::get(p.ctx()).ref_color(0, true);
|
|
||||||
for pane in geo.range_panes(rf.range()) {
|
for pane in geo.range_panes(rf.range()) {
|
||||||
let pp = p.with_clip_rect(pane.intersect(p.clip_rect()));
|
let pp = p.with_clip_rect(pane.intersect(p.clip_rect()));
|
||||||
pp.rect_stroke(rr, 0.0, Stroke::new(1.5, color), StrokeKind::Inside);
|
pp.rect_stroke(rr, 0.0, Stroke::new(1.5, Color32::from_rgb(0x2E, 0x6F, 0xD8)), StrokeKind::Inside);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -449,7 +447,7 @@ fn paint_slicers(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usiz
|
|||||||
if !r.contains(tr.left_top()) {
|
if !r.contains(tr.left_top()) {
|
||||||
continue; // overflowed the card; skip (no scroll yet)
|
continue; // overflowed the card; skip (no scroll yet)
|
||||||
}
|
}
|
||||||
let (fill, fg) = if *on { (tk.accent, tk.on_accent) } else { (tk.hover, tk.text_dim) };
|
let (fill, fg) = if *on { (tk.accent, Color32::WHITE) } else { (tk.hover, tk.text_dim) };
|
||||||
p.rect_filled(*tr, 3.0 * geo.z, fill);
|
p.rect_filled(*tr, 3.0 * geo.z, fill);
|
||||||
let cp = p.with_clip_rect(tr.intersect(p.clip_rect()));
|
let cp = p.with_clip_rect(tr.intersect(p.clip_rect()));
|
||||||
cp.text(tr.left_center() + vec2(6.0 * geo.z, 0.0), Align2::LEFT_CENTER, val, theme::ui_font(12.0 * geo.z), fg);
|
cp.text(tr.left_center() + vec2(6.0 * geo.z, 0.0), Align2::LEFT_CENTER, val, theme::ui_font(12.0 * geo.z), fg);
|
||||||
@@ -493,11 +491,10 @@ pub fn slicer_click(geo: &Geo, sh: &Sheet, wb: &Workbook, si: usize, p: Pos2) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn selection_frame(p: &Painter, r: Rect) {
|
fn selection_frame(p: &Painter, r: Rect) {
|
||||||
let t = Tokens::get(p.ctx());
|
p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_gray(120)), StrokeKind::Outside);
|
||||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Outside);
|
|
||||||
for c in [r.left_top(), r.center_top(), r.right_top(), r.left_center(), r.right_center(), r.left_bottom(), r.center_bottom(), r.right_bottom()] {
|
for c in [r.left_top(), r.center_top(), r.right_top(), r.left_center(), r.right_center(), r.left_bottom(), r.center_bottom(), r.right_bottom()] {
|
||||||
p.circle_filled(c, 4.0, t.sheet_control_bg);
|
p.circle_filled(c, 4.0, Color32::WHITE);
|
||||||
p.circle_stroke(c, 4.0, Stroke::new(1.0, t.sheet_control_border));
|
p.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::from_gray(100)));
|
||||||
}
|
}
|
||||||
let _ = Align2::CENTER_CENTER;
|
let _ = Align2::CENTER_CENTER;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,938 +0,0 @@
|
|||||||
//! A small code editor: monospace text with syntax colours, Tab to indent, Enter that keeps the
|
|
||||||
//! indentation, and a scroll area that follows the caret. It colours Python (for code in cells,
|
|
||||||
//! where `xl()` reads the sheet) and LaTeX (for equation source); reached as
|
|
||||||
//! `widgets::code_editor`. The tokenizer and the key edits are pure functions, tested below.
|
|
||||||
|
|
||||||
use std::ops::Range;
|
|
||||||
|
|
||||||
use egui::text::{CCursor, CCursorRange};
|
|
||||||
use egui::{FontFamily, FontId, Key, Response, TextEdit, Ui};
|
|
||||||
|
|
||||||
use crate::theme::{self, Syntax, Tokens};
|
|
||||||
|
|
||||||
/// The language a code editor colours.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum Lang {
|
|
||||||
Python,
|
|
||||||
Latex,
|
|
||||||
Plain,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A coloured span's kind.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum Token {
|
|
||||||
Keyword,
|
|
||||||
String,
|
|
||||||
Number,
|
|
||||||
Comment,
|
|
||||||
Builtin,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spaces one Tab inserts.
|
|
||||||
pub const INDENT: &str = " ";
|
|
||||||
|
|
||||||
const PY_KEYWORDS: &[&str] = &[
|
|
||||||
"False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif", "else", "except",
|
|
||||||
"finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "match", "case", "nonlocal", "not", "or", "pass", "raise", "return",
|
|
||||||
"try", "while", "with", "yield",
|
|
||||||
];
|
|
||||||
|
|
||||||
const PY_BUILTINS: &[&str] = &[
|
|
||||||
"abs",
|
|
||||||
"all",
|
|
||||||
"any",
|
|
||||||
"bool",
|
|
||||||
"dict",
|
|
||||||
"enumerate",
|
|
||||||
"filter",
|
|
||||||
"float",
|
|
||||||
"format",
|
|
||||||
"int",
|
|
||||||
"isinstance",
|
|
||||||
"len",
|
|
||||||
"list",
|
|
||||||
"map",
|
|
||||||
"max",
|
|
||||||
"min",
|
|
||||||
"print",
|
|
||||||
"range",
|
|
||||||
"reversed",
|
|
||||||
"round",
|
|
||||||
"set",
|
|
||||||
"sorted",
|
|
||||||
"str",
|
|
||||||
"sum",
|
|
||||||
"tuple",
|
|
||||||
"type",
|
|
||||||
"zip",
|
|
||||||
"xl",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// The coloured spans of `text` (byte ranges on char boundaries, in order, not overlapping).
|
|
||||||
/// Anything not covered is plain text. Never panics, whatever the input.
|
|
||||||
pub fn highlight(text: &str, lang: Lang) -> Vec<(Range<usize>, Token)> {
|
|
||||||
match lang {
|
|
||||||
Lang::Python => python(text),
|
|
||||||
Lang::Latex => latex(text),
|
|
||||||
Lang::Plain => Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn python(text: &str) -> Vec<(Range<usize>, Token)> {
|
|
||||||
let chars: Vec<(usize, char)> = text.char_indices().collect();
|
|
||||||
let end_of = |i: usize| chars.get(i).map(|(b, _)| *b).unwrap_or(text.len());
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let mut i = 0;
|
|
||||||
while let Some(&(start, c)) = chars.get(i) {
|
|
||||||
if c == '#' {
|
|
||||||
let mut j = i;
|
|
||||||
while chars.get(j).is_some_and(|(_, c)| *c != '\n') {
|
|
||||||
j += 1;
|
|
||||||
}
|
|
||||||
out.push((start..end_of(j), Token::Comment));
|
|
||||||
i = j;
|
|
||||||
} else if c == '"' || c == '\'' {
|
|
||||||
let triple = chars.get(i + 1).is_some_and(|(_, d)| *d == c) && chars.get(i + 2).is_some_and(|(_, d)| *d == c);
|
|
||||||
let mut j = if triple { i + 3 } else { i + 1 };
|
|
||||||
loop {
|
|
||||||
match chars.get(j) {
|
|
||||||
None => break,
|
|
||||||
Some((_, '\\')) => j += 2,
|
|
||||||
Some((_, '\n')) if !triple => break,
|
|
||||||
Some((_, d)) if *d == c => {
|
|
||||||
if !triple {
|
|
||||||
j += 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if chars.get(j + 1).is_some_and(|(_, d)| *d == c) && chars.get(j + 2).is_some_and(|(_, d)| *d == c) {
|
|
||||||
j += 3;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
j += 1;
|
|
||||||
}
|
|
||||||
Some(_) => j += 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let j = j.min(chars.len());
|
|
||||||
out.push((start..end_of(j), Token::String));
|
|
||||||
i = j;
|
|
||||||
} else if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(|(_, d)| d.is_ascii_digit())) {
|
|
||||||
let mut j = i + 1;
|
|
||||||
while chars.get(j).is_some_and(|(_, d)| d.is_ascii_alphanumeric() || *d == '_' || *d == '.') {
|
|
||||||
j += 1;
|
|
||||||
}
|
|
||||||
out.push((start..end_of(j), Token::Number));
|
|
||||||
i = j;
|
|
||||||
} else if c.is_alphabetic() || c == '_' {
|
|
||||||
let mut j = i + 1;
|
|
||||||
while chars.get(j).is_some_and(|(_, d)| d.is_alphanumeric() || *d == '_') {
|
|
||||||
j += 1;
|
|
||||||
}
|
|
||||||
let word = text.get(start..end_of(j)).unwrap_or("");
|
|
||||||
if PY_KEYWORDS.contains(&word) {
|
|
||||||
out.push((start..end_of(j), Token::Keyword));
|
|
||||||
} else if PY_BUILTINS.contains(&word) {
|
|
||||||
out.push((start..end_of(j), Token::Builtin));
|
|
||||||
}
|
|
||||||
i = j;
|
|
||||||
} else {
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn latex(text: &str) -> Vec<(Range<usize>, Token)> {
|
|
||||||
let chars: Vec<(usize, char)> = text.char_indices().collect();
|
|
||||||
let end_of = |i: usize| chars.get(i).map(|(b, _)| *b).unwrap_or(text.len());
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let mut i = 0;
|
|
||||||
while let Some(&(start, c)) = chars.get(i) {
|
|
||||||
match c {
|
|
||||||
'%' => {
|
|
||||||
let mut j = i;
|
|
||||||
while chars.get(j).is_some_and(|(_, c)| *c != '\n') {
|
|
||||||
j += 1;
|
|
||||||
}
|
|
||||||
out.push((start..end_of(j), Token::Comment));
|
|
||||||
i = j;
|
|
||||||
}
|
|
||||||
'\\' => {
|
|
||||||
let mut j = i + 1;
|
|
||||||
if chars.get(j).is_some_and(|(_, d)| d.is_ascii_alphabetic()) {
|
|
||||||
while chars.get(j).is_some_and(|(_, d)| d.is_ascii_alphabetic()) {
|
|
||||||
j += 1;
|
|
||||||
}
|
|
||||||
} else if chars.get(j).is_some() {
|
|
||||||
j += 1; // a control symbol such as \{ or \%
|
|
||||||
}
|
|
||||||
out.push((start..end_of(j), Token::Keyword));
|
|
||||||
i = j;
|
|
||||||
}
|
|
||||||
'^' | '_' | '&' => {
|
|
||||||
out.push((start..end_of(i + 1), Token::Builtin));
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
d if d.is_ascii_digit() => {
|
|
||||||
let mut j = i + 1;
|
|
||||||
while chars.get(j).is_some_and(|(_, d)| d.is_ascii_digit() || *d == '.') {
|
|
||||||
j += 1;
|
|
||||||
}
|
|
||||||
out.push((start..end_of(j), Token::Number));
|
|
||||||
i = j;
|
|
||||||
}
|
|
||||||
_ => i += 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn color(s: &Syntax, token: Token) -> egui::Color32 {
|
|
||||||
match token {
|
|
||||||
Token::Keyword => s.keyword,
|
|
||||||
Token::String => s.string,
|
|
||||||
Token::Number => s.number,
|
|
||||||
Token::Comment => s.comment,
|
|
||||||
Token::Builtin => s.builtin,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The code editor's font: the monospace face at 12.5 px.
|
|
||||||
pub fn font() -> FontId {
|
|
||||||
FontId::new(12.5, FontFamily::Name(theme::MONO.into()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A layout job for `text` with syntax colours.
|
|
||||||
pub fn layout_job(text: &str, lang: Lang, font: &FontId, plain: egui::Color32, syntax: &Syntax, wrap_width: f32) -> egui::text::LayoutJob {
|
|
||||||
let mut job = egui::text::LayoutJob::default();
|
|
||||||
let mut pos = 0usize;
|
|
||||||
for (range, token) in highlight(text, lang) {
|
|
||||||
if range.start < pos {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let (Some(before), Some(span)) = (text.get(pos..range.start), text.get(range.clone())) {
|
|
||||||
job.append(before, 0.0, egui::TextFormat::simple(font.clone(), plain));
|
|
||||||
job.append(span, 0.0, egui::TextFormat::simple(font.clone(), color(syntax, token)));
|
|
||||||
pos = range.end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
job.append(text.get(pos..).unwrap_or(""), 0.0, egui::TextFormat::simple(font.clone(), plain));
|
|
||||||
job.wrap.max_width = wrap_width;
|
|
||||||
job
|
|
||||||
}
|
|
||||||
|
|
||||||
fn byte_of(text: &str, char_index: usize) -> usize {
|
|
||||||
text.char_indices().nth(char_index).map(|(b, _)| b).unwrap_or(text.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start (char index) of the line holding char `at`.
|
|
||||||
fn line_start(text: &str, at: usize) -> usize {
|
|
||||||
let b = byte_of(text, at);
|
|
||||||
text.get(..b).and_then(|s| s.rfind('\n')).map(|nl| text.get(..nl + 1).map(|s| s.chars().count()).unwrap_or(0)).unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What a key did to the text: the new caret (and selection anchor), as char indices.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub struct Edit {
|
|
||||||
pub anchor: usize,
|
|
||||||
pub caret: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enter: a newline that keeps the current line's indentation (one level more after a Python `:`).
|
|
||||||
/// `sel` is the selection as char indices (anchor, caret); it is replaced.
|
|
||||||
pub fn newline(text: &mut String, sel: (usize, usize), lang: Lang) -> Edit {
|
|
||||||
let count = text.chars().count();
|
|
||||||
let (a, b) = (sel.0.min(sel.1).min(count), sel.0.max(sel.1).min(count));
|
|
||||||
let ls = line_start(text, a);
|
|
||||||
let line_before: String = text.chars().skip(ls).take(a - ls.min(a)).collect();
|
|
||||||
let mut indent: String = line_before.chars().take_while(|c| *c == ' ' || *c == '\t').collect();
|
|
||||||
if lang == Lang::Python && line_before.trim_end().ends_with(':') {
|
|
||||||
indent.push_str(INDENT);
|
|
||||||
}
|
|
||||||
let insert = format!("\n{indent}");
|
|
||||||
let (ba, bb) = (byte_of(text, a), byte_of(text, b));
|
|
||||||
text.replace_range(ba..bb, &insert);
|
|
||||||
let caret = a + insert.chars().count();
|
|
||||||
Edit { anchor: caret, caret }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Typed text (or a paste) replacing the selection.
|
|
||||||
pub fn insert(text: &mut String, sel: (usize, usize), s: &str) -> Edit {
|
|
||||||
let count = text.chars().count();
|
|
||||||
let (a, b) = (sel.0.min(sel.1).min(count), sel.0.max(sel.1).min(count));
|
|
||||||
let (ba, bb) = (byte_of(text, a), byte_of(text, b));
|
|
||||||
text.replace_range(ba..bb, s);
|
|
||||||
let caret = a + s.chars().count();
|
|
||||||
Edit { anchor: caret, caret }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Backspace (`back`) or Delete: the selection goes, or the character before or after the caret.
|
|
||||||
pub fn delete(text: &mut String, sel: (usize, usize), back: bool) -> Edit {
|
|
||||||
let count = text.chars().count();
|
|
||||||
let (mut a, mut b) = (sel.0.min(sel.1).min(count), sel.0.max(sel.1).min(count));
|
|
||||||
if a == b {
|
|
||||||
if back {
|
|
||||||
a = a.saturating_sub(1);
|
|
||||||
} else {
|
|
||||||
b = (b + 1).min(count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let (ba, bb) = (byte_of(text, a), byte_of(text, b));
|
|
||||||
text.replace_range(ba..bb, "");
|
|
||||||
Edit { anchor: a, caret: a }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An edit the editor makes itself, read off an input event by `classify`.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
enum EditKey<'a> {
|
|
||||||
Insert(&'a str),
|
|
||||||
Enter,
|
|
||||||
Tab { dedent: bool },
|
|
||||||
Backspace,
|
|
||||||
Delete,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What one input event means to the editor. See `apply_in_order`.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
enum Class<'a> {
|
|
||||||
/// The editor applies it: typed text, a paste, Enter, Tab, Shift+Tab, Backspace, Delete.
|
|
||||||
Edit(EditKey<'a>),
|
|
||||||
/// The editor takes it off the queue and does nothing: Tab under a command or option
|
|
||||||
/// modifier, which the field would turn into a tab character in place of the selection.
|
|
||||||
Dropped,
|
|
||||||
/// The text field acts on it: a caret or selection key, a command shortcut (select all,
|
|
||||||
/// undo, word deletion), IME, Copy, Cut, or a click, which the field reads from the pointer
|
|
||||||
/// state.
|
|
||||||
Field,
|
|
||||||
/// Neither reads it from the queue: a key release, a printable key press (its character
|
|
||||||
/// arrives as the `Text` event after it, also under Shift or Option), a pointer move, Escape
|
|
||||||
/// (the focus system acts on it when the pass begins).
|
|
||||||
Other,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn classify(ev: &egui::Event) -> Class<'_> {
|
|
||||||
use egui::Event;
|
|
||||||
match ev {
|
|
||||||
// The field treats a lone newline as the Enter key's business, and ignores it.
|
|
||||||
Event::Text(t) if t != "\n" && t != "\r" => Class::Edit(EditKey::Insert(t)),
|
|
||||||
Event::Text(_) => Class::Other,
|
|
||||||
Event::Paste(t) => Class::Edit(EditKey::Insert(t)),
|
|
||||||
Event::Key { key, pressed: true, modifiers, .. } => {
|
|
||||||
let command = modifiers.command || modifiers.ctrl || modifiers.mac_cmd;
|
|
||||||
match key {
|
|
||||||
// The field makes a newline of Enter under any modifier but a command one, and
|
|
||||||
// a tab character of Tab under every modifier; the editor takes all of those.
|
|
||||||
Key::Enter if !command => Class::Edit(EditKey::Enter),
|
|
||||||
Key::Tab if !command && !modifiers.alt => Class::Edit(EditKey::Tab { dedent: modifiers.shift }),
|
|
||||||
Key::Tab => Class::Dropped,
|
|
||||||
Key::Backspace if modifiers.is_none() => Class::Edit(EditKey::Backspace),
|
|
||||||
Key::Delete if modifiers.is_none() => Class::Edit(EditKey::Delete),
|
|
||||||
// The keys egui's text field reads: caret and selection moves, word and line
|
|
||||||
// deletion, and its command shortcuts (select all, undo, redo, the Emacs keys).
|
|
||||||
Key::Enter | Key::Backspace | Key::Delete => Class::Field,
|
|
||||||
Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | Key::Home | Key::End => Class::Field,
|
|
||||||
_ if command => Class::Field,
|
|
||||||
_ => Class::Other,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Event::Copy | Event::Cut | Event::Ime(_) | Event::PointerButton { pressed: true, .. } => Class::Field,
|
|
||||||
_ => Class::Other,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether an event is part of the keyboard stream, whose order the editor keeps. Pointer,
|
|
||||||
/// touch, scroll and window events are not: egui folds them into its input state when the
|
|
||||||
/// pass begins, so they act in the pass they arrive in whatever their place among the keys.
|
|
||||||
fn is_keyboard(ev: &egui::Event) -> bool {
|
|
||||||
use egui::Event;
|
|
||||||
matches!(ev, Event::Key { .. } | Event::Text(_) | Event::Paste(_) | Event::Copy | Event::Cut | Event::Ime(_))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply(key: EditKey<'_>, text: &mut String, sel: (usize, usize), lang: Lang) -> Edit {
|
|
||||||
match key {
|
|
||||||
EditKey::Insert(s) => insert(text, sel, s),
|
|
||||||
EditKey::Enter => newline(text, sel, lang),
|
|
||||||
EditKey::Tab { dedent } => indent(text, sel, dedent),
|
|
||||||
EditKey::Backspace => delete(text, sel, true),
|
|
||||||
EditKey::Delete => delete(text, sel, false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What `apply_in_order` did with a pass's events.
|
|
||||||
#[derive(Debug, Default, PartialEq)]
|
|
||||||
pub struct Applied {
|
|
||||||
/// Whether the text or the selection changed.
|
|
||||||
pub edited: bool,
|
|
||||||
/// The keyboard events held back for the next pass, in order: the editor's first key after
|
|
||||||
/// an event the text field acts on, and every keyboard event after that key.
|
|
||||||
pub held: Vec<egui::Event>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Applies the pass's input to `text` in the order it came. A pass can carry several events
|
|
||||||
/// (typing between repaints, key repeat), and two readers take them: this editor, for typed
|
|
||||||
/// text, pastes, Enter, Tab, Shift+Tab, Backspace and Delete, and then egui's text field, for
|
|
||||||
/// the caret and selection keys, command shortcuts, IME and clicks. The editor runs first, so
|
|
||||||
/// it can only apply its keys up to the first event the field acts on: its edits before that
|
|
||||||
/// event are applied and taken from the queue, the field's event and the run after it stay in
|
|
||||||
/// the queue for the field, and from the editor's next key on the keyboard events are held
|
|
||||||
/// back (`Applied::held`) for the next pass, where `code_editor` puts them ahead of that
|
|
||||||
/// pass's input and they go through this again. So select all, Tab in one pass indents every
|
|
||||||
/// line in two passes, and the field never gets an Enter or a Tab of a focused editor.
|
|
||||||
/// Printable key presses do not split the stream: the field reads the `Text` event that
|
|
||||||
/// follows each one, not the key. `sel` is the selection as char indices (anchor, caret),
|
|
||||||
/// updated.
|
|
||||||
pub fn apply_in_order(events: &mut Vec<egui::Event>, text: &mut String, sel: &mut (usize, usize), lang: Lang) -> Applied {
|
|
||||||
let mut out = Applied::default();
|
|
||||||
// An event the field acts on has gone by: the editor's keys after it wait for the next pass.
|
|
||||||
let mut field_seen = false;
|
|
||||||
// A key is waiting: every keyboard event after it waits too, so the order holds.
|
|
||||||
let mut holding = false;
|
|
||||||
let mut keep = Vec::with_capacity(events.len());
|
|
||||||
for ev in std::mem::take(events) {
|
|
||||||
if holding && is_keyboard(&ev) {
|
|
||||||
out.held.push(ev);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match classify(&ev) {
|
|
||||||
Class::Edit(_) if field_seen => {
|
|
||||||
holding = true;
|
|
||||||
out.held.push(ev);
|
|
||||||
}
|
|
||||||
Class::Edit(key) => {
|
|
||||||
let e = apply(key, text, *sel, lang);
|
|
||||||
*sel = (e.anchor, e.caret);
|
|
||||||
out.edited = true;
|
|
||||||
}
|
|
||||||
Class::Dropped => {}
|
|
||||||
Class::Field => {
|
|
||||||
field_seen = true;
|
|
||||||
keep.push(ev);
|
|
||||||
}
|
|
||||||
Class::Other => keep.push(ev),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*events = keep;
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The keyboard events a code editor held back, with the pass that held them.
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
struct Held {
|
|
||||||
pass: u64,
|
|
||||||
events: Vec<egui::Event>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn held_id(id: egui::Id) -> egui::Id {
|
|
||||||
id.with("held")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The events the editor `id` held back in the previous pass, to go ahead of this pass's input
|
|
||||||
/// (see `apply_in_order`). Only the editor they were typed into gets them, only while it still
|
|
||||||
/// has the focus, and only in the pass right after: once the focus has moved on, or the editor
|
|
||||||
/// was not shown in between, they are dropped rather than typed into another widget.
|
|
||||||
fn take_held(ui: &Ui, id: egui::Id, focused: bool) -> Vec<egui::Event> {
|
|
||||||
let pass = ui.ctx().cumulative_pass_nr();
|
|
||||||
ui.ctx()
|
|
||||||
.data_mut(|d| d.remove_temp::<Held>(held_id(id)))
|
|
||||||
.filter(|h| focused && h.pass.checked_add(1) == Some(pass))
|
|
||||||
.map(|h| h.events)
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Holds `events` back for the editor's next pass, and asks for that pass.
|
|
||||||
fn hold(ui: &Ui, id: egui::Id, events: Vec<egui::Event>) {
|
|
||||||
let pass = ui.ctx().cumulative_pass_nr();
|
|
||||||
ui.ctx().data_mut(|d| d.insert_temp(held_id(id), Held { pass, events }));
|
|
||||||
ui.ctx().request_repaint();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tab (indent) or Shift+Tab (dedent). A selection over several lines indents or dedents each
|
|
||||||
/// line; otherwise Tab inserts four spaces at the caret.
|
|
||||||
pub fn indent(text: &mut String, sel: (usize, usize), dedent: bool) -> Edit {
|
|
||||||
let count = text.chars().count();
|
|
||||||
let (a, b) = (sel.0.min(sel.1).min(count), sel.0.max(sel.1).min(count));
|
|
||||||
let multi = text.chars().skip(a).take(b - a).any(|c| c == '\n');
|
|
||||||
if !dedent && !multi {
|
|
||||||
let (ba, bb) = (byte_of(text, a), byte_of(text, b));
|
|
||||||
text.replace_range(ba..bb, INDENT);
|
|
||||||
let caret = a + INDENT.len();
|
|
||||||
return Edit { anchor: caret, caret };
|
|
||||||
}
|
|
||||||
// Line starts (char indices) of every line the selection touches. A selection that ends at
|
|
||||||
// the start of the next line (`"a\n"` in `"a\nb"`) does not touch that line.
|
|
||||||
let mut starts = vec![line_start(text, a)];
|
|
||||||
for (i, c) in text.chars().enumerate().skip(a).take(b - a) {
|
|
||||||
if c == '\n' && i + 1 < b {
|
|
||||||
starts.push(i + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let (mut na, mut nb) = (a, b);
|
|
||||||
// Work from the last line back so earlier indices stay valid.
|
|
||||||
for &s in starts.iter().rev() {
|
|
||||||
let bs = byte_of(text, s);
|
|
||||||
let n = if dedent {
|
|
||||||
let n = text.get(bs..).map(|rest| rest.chars().take(INDENT.len()).take_while(|c| *c == ' ').count()).unwrap_or(0);
|
|
||||||
text.replace_range(bs..bs + n, "");
|
|
||||||
n
|
|
||||||
} else {
|
|
||||||
text.insert_str(bs, INDENT);
|
|
||||||
0
|
|
||||||
};
|
|
||||||
let shift = |p: usize| {
|
|
||||||
if dedent {
|
|
||||||
if p > s { p - n.min(p - s) } else { p }
|
|
||||||
} else if p >= s {
|
|
||||||
p + INDENT.len()
|
|
||||||
} else {
|
|
||||||
p
|
|
||||||
}
|
|
||||||
};
|
|
||||||
na = shift(na);
|
|
||||||
nb = shift(nb);
|
|
||||||
}
|
|
||||||
if sel.1 >= sel.0 { Edit { anchor: na, caret: nb } } else { Edit { anchor: nb, caret: na } }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The code editor. `max_lines` rows are shown before it scrolls; the scroll area follows the
|
|
||||||
/// caret.
|
|
||||||
pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max_lines: usize) -> Response {
|
|
||||||
let t = Tokens::get(ui.ctx());
|
|
||||||
let font = font();
|
|
||||||
let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font));
|
|
||||||
let max_lines = max_lines.clamp(1, 200);
|
|
||||||
// The input the editor handles itself (Enter with indentation, Tab, Shift+Tab), applied in
|
|
||||||
// input order with the typing around it, before the text field sees the rest; what had to
|
|
||||||
// wait for the field in the previous pass comes first.
|
|
||||||
let focused = ui.memory(|m| m.has_focus(id));
|
|
||||||
let held = take_held(ui, id, focused);
|
|
||||||
let mut edited = None;
|
|
||||||
if focused {
|
|
||||||
let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
|
|
||||||
let count = text.chars().count();
|
|
||||||
let mut sel = state.cursor.char_range().map(|r| (r.secondary.index.into(), r.primary.index.into())).unwrap_or((count, count));
|
|
||||||
let applied = ui.input_mut(|i| {
|
|
||||||
if !held.is_empty() {
|
|
||||||
let mut events = held;
|
|
||||||
events.append(&mut i.events);
|
|
||||||
i.events = events;
|
|
||||||
}
|
|
||||||
apply_in_order(&mut i.events, text, &mut sel, lang)
|
|
||||||
});
|
|
||||||
if applied.edited {
|
|
||||||
state.cursor.set_char_range(Some(CCursorRange::two(CCursor::new(sel.0), CCursor::new(sel.1))));
|
|
||||||
state.store(ui.ctx(), id);
|
|
||||||
edited = Some(Edit { anchor: sel.0, caret: sel.1 });
|
|
||||||
}
|
|
||||||
if !applied.held.is_empty() {
|
|
||||||
hold(ui, id, applied.held);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let syntax = t.syntax;
|
|
||||||
let plain = t.text;
|
|
||||||
let mut layouter = |ui: &Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
|
|
||||||
let job = layout_job(buf.as_str(), lang, &self::font(), plain, &syntax, wrap_width);
|
|
||||||
ui.fonts_mut(|f| f.layout_job(job))
|
|
||||||
};
|
|
||||||
let frame_h = row_h * max_lines as f32 + 8.0;
|
|
||||||
let mut response = None;
|
|
||||||
egui::Frame::new().fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).corner_radius(4.0).inner_margin(4.0).show(ui, |ui| {
|
|
||||||
egui::ScrollArea::vertical().id_salt(id.with("scroll")).max_height(frame_h - 8.0).auto_shrink([false, true]).show(ui, |ui| {
|
|
||||||
let out = TextEdit::multiline(text)
|
|
||||||
.id(id)
|
|
||||||
.font(font.clone())
|
|
||||||
.code_editor()
|
|
||||||
.frame(egui::Frame::NONE)
|
|
||||||
.desired_width(f32::INFINITY)
|
|
||||||
.desired_rows(1)
|
|
||||||
.lock_focus(true)
|
|
||||||
.layouter(&mut layouter)
|
|
||||||
.show(ui);
|
|
||||||
if let Some(e) = edited {
|
|
||||||
let caret = out.galley.pos_from_cursor(CCursor::new(e.caret)).translate(out.galley_pos.to_vec2());
|
|
||||||
ui.scroll_to_rect(caret, None);
|
|
||||||
}
|
|
||||||
let mut r = out.response.response;
|
|
||||||
if edited.is_some() {
|
|
||||||
r.mark_changed();
|
|
||||||
}
|
|
||||||
response = Some(r);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
response.unwrap_or_else(|| ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use egui::Modifiers;
|
|
||||||
|
|
||||||
fn spans(text: &str, lang: Lang) -> Vec<(&str, Token)> {
|
|
||||||
highlight(text, lang).into_iter().map(|(r, k)| (text.get(r).unwrap_or("?"), k)).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn python_tokens() {
|
|
||||||
let s = spans("def f(x):\n return len(x) + 1.5 # done\nxl(\"A1:B2\")", Lang::Python);
|
|
||||||
assert_eq!(
|
|
||||||
s,
|
|
||||||
vec![
|
|
||||||
("def", Token::Keyword),
|
|
||||||
("return", Token::Keyword),
|
|
||||||
("len", Token::Builtin),
|
|
||||||
("1.5", Token::Number),
|
|
||||||
("# done", Token::Comment),
|
|
||||||
("xl", Token::Builtin),
|
|
||||||
("\"A1:B2\"", Token::String),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert_eq!(spans("s = '''a\n'b'\nc'''", Lang::Python), vec![("'''a\n'b'\nc'''", Token::String)]);
|
|
||||||
assert_eq!(spans("x = 'it\\'s'", Lang::Python), vec![("'it\\'s'", Token::String)]);
|
|
||||||
assert_eq!(spans("hello", Lang::Plain), vec![]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn latex_tokens() {
|
|
||||||
let s = spans("\\frac{a^2}{3} % half\n\\{", Lang::Latex);
|
|
||||||
assert_eq!(
|
|
||||||
s,
|
|
||||||
vec![
|
|
||||||
("\\frac", Token::Keyword),
|
|
||||||
("^", Token::Builtin),
|
|
||||||
("2", Token::Number),
|
|
||||||
("3", Token::Number),
|
|
||||||
("% half", Token::Comment),
|
|
||||||
("\\{", Token::Keyword),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hostile_input_never_panics_and_spans_stay_on_char_boundaries() {
|
|
||||||
let inputs = [
|
|
||||||
"",
|
|
||||||
"'",
|
|
||||||
"\"",
|
|
||||||
"'''",
|
|
||||||
"\"\"\"x",
|
|
||||||
"#",
|
|
||||||
"\\",
|
|
||||||
"%",
|
|
||||||
"1",
|
|
||||||
".",
|
|
||||||
"é'ü\\",
|
|
||||||
"θ = 'π' # ∑",
|
|
||||||
"\u{1F600}'\u{1F600}",
|
|
||||||
"'\\",
|
|
||||||
"\\\u{1F600}",
|
|
||||||
"\u{200B}\n\t'''\n",
|
|
||||||
"0x_é",
|
|
||||||
"a.b.c",
|
|
||||||
"____",
|
|
||||||
];
|
|
||||||
for text in inputs {
|
|
||||||
for lang in [Lang::Python, Lang::Latex, Lang::Plain] {
|
|
||||||
let mut last = 0;
|
|
||||||
for (r, _) in highlight(text, lang) {
|
|
||||||
assert!(r.start >= last && r.start <= r.end && r.end <= text.len(), "{text:?} {lang:?} {r:?}");
|
|
||||||
assert!(text.is_char_boundary(r.start) && text.is_char_boundary(r.end), "{text:?} {lang:?} {r:?}");
|
|
||||||
last = r.end;
|
|
||||||
}
|
|
||||||
let job = layout_job(text, lang, &FontId::default(), egui::Color32::BLACK, &Tokens::light().syntax, f32::INFINITY);
|
|
||||||
assert_eq!(job.text, text, "every character is laid out once");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enter_keeps_indentation_and_indents_after_a_colon() {
|
|
||||||
let mut s = " x = 1".to_string();
|
|
||||||
let e = newline(&mut s, (9, 9), Lang::Python);
|
|
||||||
assert_eq!(s, " x = 1\n ");
|
|
||||||
assert_eq!(e.caret, 14);
|
|
||||||
let mut s = "if x:".to_string();
|
|
||||||
let e = newline(&mut s, (5, 5), Lang::Python);
|
|
||||||
assert_eq!(s, "if x:\n ");
|
|
||||||
assert_eq!(e.caret, 10);
|
|
||||||
// Plain text keeps the indentation but adds none after a colon.
|
|
||||||
let mut s = " a:".to_string();
|
|
||||||
newline(&mut s, (4, 4), Lang::Plain);
|
|
||||||
assert_eq!(s, " a:\n ");
|
|
||||||
// A selection is replaced; out-of-range carets are clamped.
|
|
||||||
let mut s = "abcdef".to_string();
|
|
||||||
let e = newline(&mut s, (4, 2), Lang::Plain);
|
|
||||||
assert_eq!((s.as_str(), e.caret), ("ab\nef", 3));
|
|
||||||
let mut s = "é".to_string();
|
|
||||||
let e = newline(&mut s, (99, 99), Lang::Plain);
|
|
||||||
assert_eq!((s.as_str(), e.caret), ("é\n", 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shift_tab_in_the_editor_dedents_rather_than_indents() {
|
|
||||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state(
|
|
||||||
|ui, text: &mut String| {
|
|
||||||
if ui.ctx().cumulative_pass_nr() == 0 {
|
|
||||||
let mut fonts = egui::FontDefinitions::default();
|
|
||||||
let mono = fonts.families.get(&egui::FontFamily::Monospace).cloned().unwrap_or_default();
|
|
||||||
fonts.families.insert(egui::FontFamily::Name(theme::MONO.into()), mono);
|
|
||||||
ui.ctx().set_fonts(fonts);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let id = egui::Id::new("code");
|
|
||||||
ui.memory_mut(|m| m.request_focus(id));
|
|
||||||
code_editor(ui, id, text, Lang::Python, 5);
|
|
||||||
},
|
|
||||||
" x".to_string(),
|
|
||||||
);
|
|
||||||
h.run_steps(2);
|
|
||||||
let tab = |modifiers| egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers };
|
|
||||||
h.input_mut().events.push(tab(Modifiers::SHIFT));
|
|
||||||
h.run_steps(1);
|
|
||||||
assert_eq!(h.state(), " x");
|
|
||||||
// A plain Tab still inserts spaces at the caret (the end).
|
|
||||||
h.input_mut().events.push(tab(Modifiers::NONE));
|
|
||||||
h.run_steps(1);
|
|
||||||
assert_eq!(h.state(), " x ");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gives the context the editor's monospace family (the test has no craft fonts).
|
|
||||||
fn install_mono(ctx: &egui::Context) {
|
|
||||||
let mut fonts = egui::FontDefinitions::default();
|
|
||||||
let mono = fonts.families.get(&egui::FontFamily::Monospace).cloned().unwrap_or_default();
|
|
||||||
fonts.families.insert(egui::FontFamily::Name(theme::MONO.into()), mono);
|
|
||||||
ctx.set_fonts(fonts);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn editor_harness(text: &str) -> egui_kittest::Harness<'static, String> {
|
|
||||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state(
|
|
||||||
|ui, text: &mut String| {
|
|
||||||
if ui.ctx().cumulative_pass_nr() == 0 {
|
|
||||||
install_mono(ui.ctx());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let id = egui::Id::new("code");
|
|
||||||
ui.memory_mut(|m| m.request_focus(id));
|
|
||||||
code_editor(ui, id, text, Lang::Python, 5);
|
|
||||||
},
|
|
||||||
text.to_string(),
|
|
||||||
);
|
|
||||||
h.run_steps(2);
|
|
||||||
h
|
|
||||||
}
|
|
||||||
|
|
||||||
fn key(key: Key, modifiers: Modifiers) -> egui::Event {
|
|
||||||
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn batched_input_applies_in_order_and_keeps_every_key() {
|
|
||||||
// Events gather between repaints (typing, key repeat). The text typed before an Enter
|
|
||||||
// comes first, and two Enters make two lines.
|
|
||||||
let mut h = editor_harness("");
|
|
||||||
h.input_mut().events.extend([egui::Event::Text("if x:".into()), key(Key::Enter, Modifiers::NONE), key(Key::Enter, Modifiers::NONE)]);
|
|
||||||
h.run_steps(1);
|
|
||||||
assert_eq!(h.state(), "if x:\n \n ");
|
|
||||||
// Backspace and Tab in the same frame as the typing around them.
|
|
||||||
h.input_mut().events.extend([
|
|
||||||
egui::Event::Text("y = 1".into()),
|
|
||||||
key(Key::Backspace, Modifiers::NONE),
|
|
||||||
egui::Event::Text("2".into()),
|
|
||||||
key(Key::Tab, Modifiers::NONE),
|
|
||||||
]);
|
|
||||||
h.run_steps(1);
|
|
||||||
assert_eq!(h.state(), "if x:\n \n y = 2 ");
|
|
||||||
// An event the field acts on (an arrow) keeps the order too: the text typed before it
|
|
||||||
// is applied first, the field moves the caret, and the Enter after it waits for the
|
|
||||||
// next pass, where the editor applies it at the moved caret, with the indentation.
|
|
||||||
h.input_mut().events.extend([egui::Event::Text("a".into()), key(Key::ArrowLeft, Modifiers::NONE), key(Key::Enter, Modifiers::NONE)]);
|
|
||||||
h.run_steps(1);
|
|
||||||
assert_eq!(h.state(), "if x:\n \n y = 2 a", "the Enter waits for the arrow");
|
|
||||||
h.run_steps(1);
|
|
||||||
assert_eq!(h.state(), "if x:\n \n y = 2 \n a");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_selection_ending_at_a_line_start_leaves_that_line_alone() {
|
|
||||||
// Selecting "a\n" in "a\nb" ends at the start of the second line, which is not selected.
|
|
||||||
let mut s = "a\nb".to_string();
|
|
||||||
indent(&mut s, (0, 2), false);
|
|
||||||
assert_eq!(s, " a\nb");
|
|
||||||
let mut s = " a\n b".to_string();
|
|
||||||
indent(&mut s, (0, 6), true);
|
|
||||||
assert_eq!(s, "a\n b");
|
|
||||||
// One character into the second line, both lines are touched.
|
|
||||||
let mut s = "a\nb".to_string();
|
|
||||||
indent(&mut s, (0, 3), false);
|
|
||||||
assert_eq!(s, " a\n b");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn typed_text_and_deletions_replace_the_selection() {
|
|
||||||
let mut s = "abcd".to_string();
|
|
||||||
assert_eq!(insert(&mut s, (3, 1), "X"), Edit { anchor: 2, caret: 2 });
|
|
||||||
assert_eq!(s, "aXd");
|
|
||||||
assert_eq!(delete(&mut s, (2, 2), true), Edit { anchor: 1, caret: 1 });
|
|
||||||
assert_eq!(s, "ad");
|
|
||||||
assert_eq!(delete(&mut s, (1, 1), false), Edit { anchor: 1, caret: 1 });
|
|
||||||
assert_eq!(s, "a");
|
|
||||||
assert_eq!(delete(&mut s, (0, 0), true), Edit { anchor: 0, caret: 0 });
|
|
||||||
assert_eq!(delete(&mut s, (9, 9), false), Edit { anchor: 1, caret: 1 });
|
|
||||||
assert_eq!(s, "a");
|
|
||||||
let mut s = "éa".to_string();
|
|
||||||
delete(&mut s, (2, 0), true);
|
|
||||||
assert_eq!(s, "");
|
|
||||||
// The queue keeps what the editor does not handle, and everything after it.
|
|
||||||
let mut events = vec![
|
|
||||||
egui::Event::Text("x".into()),
|
|
||||||
egui::Event::Text("\n".into()),
|
|
||||||
key(Key::ArrowLeft, Modifiers::NONE),
|
|
||||||
key(Key::Enter, Modifiers::NONE),
|
|
||||||
];
|
|
||||||
let (mut s, mut sel) = (String::new(), (0, 0));
|
|
||||||
let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python);
|
|
||||||
assert!(out.edited);
|
|
||||||
assert_eq!((s.as_str(), sel), ("x", (1, 1)));
|
|
||||||
assert_eq!(events.len(), 2, "the lone newline and the arrow stay for the field: {events:?}");
|
|
||||||
assert_eq!(out.held, vec![key(Key::Enter, Modifiers::NONE)], "the Enter after the arrow waits for the next pass");
|
|
||||||
let mut events = vec![egui::Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }];
|
|
||||||
let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python);
|
|
||||||
assert!(!out.edited && out.held.is_empty(), "a key release is nothing to apply");
|
|
||||||
assert_eq!(events.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn select_all_then_tab_in_one_pass_indents_every_line() {
|
|
||||||
// Cmd+A is the field's, Tab the editor's: the Tab waits for the pass after the
|
|
||||||
// selection and indents the selected lines, rather than reaching the field, which would
|
|
||||||
// replace the selection with a tab character.
|
|
||||||
let mut h = editor_harness("a\nb");
|
|
||||||
h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE)]);
|
|
||||||
h.run_steps(1);
|
|
||||||
assert_eq!(h.state(), "a\nb", "the Tab waits for the selection");
|
|
||||||
h.run_steps(1);
|
|
||||||
assert_eq!(h.state(), " a\n b");
|
|
||||||
h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::SHIFT)]);
|
|
||||||
h.run_steps(2);
|
|
||||||
assert_eq!(h.state(), "a\nb");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_keystroke_sequence_as_the_platform_sends_it_keeps_the_editors_enter() {
|
|
||||||
// egui-winit sends a Key event before each Text event. Those presses are nobody's to
|
|
||||||
// read, so they do not hand the Enter to the field, which knows no indentation.
|
|
||||||
let mut h = editor_harness("");
|
|
||||||
let typed = |k, c: &str, m| [key(k, m), egui::Event::Text(c.into())];
|
|
||||||
let mut events = Vec::new();
|
|
||||||
events.extend(typed(Key::I, "i", Modifiers::NONE));
|
|
||||||
events.extend(typed(Key::F, "f", Modifiers::NONE));
|
|
||||||
events.extend(typed(Key::Space, " ", Modifiers::NONE));
|
|
||||||
events.extend(typed(Key::X, "x", Modifiers::NONE));
|
|
||||||
events.extend(typed(Key::Colon, ":", Modifiers::SHIFT));
|
|
||||||
events.push(key(Key::Enter, Modifiers::NONE));
|
|
||||||
h.input_mut().events.extend(events);
|
|
||||||
h.run_steps(1);
|
|
||||||
assert_eq!(h.state(), "if x:\n ");
|
|
||||||
// A character typed with Option (macOS) comes as a key under that modifier and its text;
|
|
||||||
// the field reads neither the key nor Option, so the Enter after it is still the editor's.
|
|
||||||
h.input_mut().events.extend([key(Key::E, Modifiers::ALT), egui::Event::Text("\u{e9}".into()), key(Key::Enter, Modifiers::NONE)]);
|
|
||||||
h.run_steps(1);
|
|
||||||
assert_eq!(h.state(), "if x:\n \u{e9}\n ");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tab_and_enter_under_modifiers_never_reach_the_field() {
|
|
||||||
// The field would make a tab character of Ctrl+Tab (in place of the selection) and a
|
|
||||||
// newline of Shift+Enter; Cmd+Enter is nobody's here and stays for a shortcut.
|
|
||||||
let mut events = vec![key(Key::Tab, Modifiers::CTRL), key(Key::Enter, Modifiers::SHIFT), key(Key::Enter, Modifiers::COMMAND)];
|
|
||||||
let (mut s, mut sel) = ("if x:".to_string(), (5, 5));
|
|
||||||
let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python);
|
|
||||||
assert_eq!(s, "if x:\n ");
|
|
||||||
assert_eq!(events, vec![key(Key::Enter, Modifiers::COMMAND)]);
|
|
||||||
assert!(out.held.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn events_after_a_held_key_wait_with_it_and_pointer_events_do_not() {
|
|
||||||
let mut events = vec![
|
|
||||||
key(Key::X, Modifiers::NONE),
|
|
||||||
egui::Event::Text("x".into()),
|
|
||||||
key(Key::A, Modifiers::COMMAND),
|
|
||||||
key(Key::Tab, Modifiers::NONE),
|
|
||||||
egui::Event::PointerMoved(egui::Pos2::ZERO),
|
|
||||||
key(Key::ArrowLeft, Modifiers::NONE),
|
|
||||||
egui::Event::Text("y".into()),
|
|
||||||
];
|
|
||||||
let (mut s, mut sel) = (String::new(), (0, 0));
|
|
||||||
let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python);
|
|
||||||
assert_eq!((s.as_str(), sel), ("x", (1, 1)), "the typing before the select-all is applied");
|
|
||||||
assert_eq!(events, vec![key(Key::X, Modifiers::NONE), key(Key::A, Modifiers::COMMAND), egui::Event::PointerMoved(egui::Pos2::ZERO)]);
|
|
||||||
assert_eq!(out.held, vec![key(Key::Tab, Modifiers::NONE), key(Key::ArrowLeft, Modifiers::NONE), egui::Event::Text("y".into())]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn held_back_events_land_in_the_next_pass_only() {
|
|
||||||
let mut h = editor_harness("");
|
|
||||||
let id = egui::Id::new("code");
|
|
||||||
let held = |pass, c: &str| Held { pass, events: vec![egui::Event::Text(c.into())] };
|
|
||||||
// What the pass just completed held back comes in with the next one.
|
|
||||||
let last = h.ctx.cumulative_pass_nr() - 1;
|
|
||||||
h.ctx.data_mut(|d| d.insert_temp(held_id(id), held(last, "a")));
|
|
||||||
h.run_steps(1);
|
|
||||||
assert_eq!(h.state(), "a");
|
|
||||||
// Anything older is dropped rather than typed out of context, and cleared.
|
|
||||||
h.ctx.data_mut(|d| d.insert_temp(held_id(id), held(last, "b")));
|
|
||||||
h.run_steps(1);
|
|
||||||
assert_eq!(h.state(), "a");
|
|
||||||
assert!(h.ctx.data(|d| d.get_temp::<Held>(held_id(id))).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn held_back_events_go_nowhere_once_the_editor_lost_the_focus() {
|
|
||||||
// Typed into the editor, they must not land in whatever has the focus next.
|
|
||||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state(
|
|
||||||
|ui, s: &mut (String, String, bool)| {
|
|
||||||
if ui.ctx().cumulative_pass_nr() == 0 {
|
|
||||||
install_mono(ui.ctx());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let (code, other) = (egui::Id::new("code"), egui::Id::new("other"));
|
|
||||||
ui.memory_mut(|m| m.request_focus(if s.2 { code } else { other }));
|
|
||||||
code_editor(ui, code, &mut s.0, Lang::Python, 5);
|
|
||||||
ui.add(TextEdit::singleline(&mut s.1).id(other));
|
|
||||||
},
|
|
||||||
(String::new(), String::new(), true),
|
|
||||||
);
|
|
||||||
h.run_steps(2);
|
|
||||||
h.input_mut().events.extend([key(Key::ArrowLeft, Modifiers::NONE), egui::Event::Text("x".into())]);
|
|
||||||
h.run_steps(1);
|
|
||||||
assert_eq!(h.state().0, "", "the x waits for the arrow");
|
|
||||||
h.state_mut().2 = false;
|
|
||||||
h.run_steps(2);
|
|
||||||
assert_eq!((h.state().0.as_str(), h.state().1.as_str()), ("", ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_repeat_pass_of_the_frame_counts_as_the_next_pass() {
|
|
||||||
// When a widget asks egui to discard and repeat the pass, the repeat pass gets the
|
|
||||||
// held-back keys, with the selection the field made in the first pass.
|
|
||||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state(
|
|
||||||
|ui, s: &mut (String, bool)| {
|
|
||||||
if ui.ctx().cumulative_pass_nr() == 0 {
|
|
||||||
install_mono(ui.ctx());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let id = egui::Id::new("code");
|
|
||||||
ui.memory_mut(|m| m.request_focus(id));
|
|
||||||
code_editor(ui, id, &mut s.0, Lang::Python, 5);
|
|
||||||
if std::mem::take(&mut s.1) {
|
|
||||||
ui.ctx().request_discard("the test repeats this pass");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
("a\nb".to_string(), false),
|
|
||||||
);
|
|
||||||
h.run_steps(2);
|
|
||||||
h.state_mut().1 = true;
|
|
||||||
h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE)]);
|
|
||||||
h.run_steps(1);
|
|
||||||
assert_eq!(h.state().0, " a\n b");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -230,7 +230,6 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
|||||||
for (id, label, params) in [
|
for (id, label, params) in [
|
||||||
("ui.ribbonTab", "Ribbon Tab", ""),
|
("ui.ribbonTab", "Ribbon Tab", ""),
|
||||||
("view.collapseRibbon", "Collapse Ribbon", ""),
|
("view.collapseRibbon", "Collapse Ribbon", ""),
|
||||||
("view.expandFormulaBar", "Expand Formula Bar", "{on?: bool} (toggles without it) → {on}"),
|
|
||||||
("view.darkMode", "Dark Mode", ""),
|
("view.darkMode", "Dark Mode", ""),
|
||||||
("view.theme", "Display Theme", ""),
|
("view.theme", "Display Theme", ""),
|
||||||
("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"),
|
("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"),
|
||||||
|
|||||||
+18
-148
@@ -343,7 +343,7 @@ impl Dialog {
|
|||||||
// The file rides along in the values (`path` on the desktop, `name` and `base64`
|
// The file rides along in the values (`path` on the desktop, `name` and `base64`
|
||||||
// on the web) and goes back to `file.open` with the password.
|
// on the web) and goes back to `file.open` with the password.
|
||||||
let source = p("path").as_str().or(p("name").as_str()).map(str::to_string).unwrap_or_default();
|
let source = p("path").as_str().or(p("name").as_str()).map(str::to_string).unwrap_or_default();
|
||||||
let file = std::path::Path::new(&source).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("This workbook").to_string();
|
let file = std::path::Path::new(&source).file_name().and_then(|n| n.to_str()).unwrap_or("This workbook").to_string();
|
||||||
// An empty password from the start, so OK on the untouched field says "incorrect"
|
// An empty password from the start, so OK on the untouched field says "incorrect"
|
||||||
// rather than asking for the password again.
|
// rather than asking for the password again.
|
||||||
let mut values = params.clone();
|
let mut values = params.clone();
|
||||||
@@ -642,20 +642,6 @@ impl Dialog {
|
|||||||
],
|
],
|
||||||
json!({"groupBy": "A", "function": "sum"}),
|
json!({"groupBy": "A", "function": "sum"}),
|
||||||
),
|
),
|
||||||
"outlineSettings" => {
|
|
||||||
let (below, right) =
|
|
||||||
app.session.active().and_then(|d| d.wb.active().map(|s| (s.summary_below, s.summary_right))).unwrap_or((true, true));
|
|
||||||
Dialog::form(
|
|
||||||
"outlineSettings",
|
|
||||||
"Settings",
|
|
||||||
"data.outlineSettings",
|
|
||||||
vec![
|
|
||||||
Check { key: "summaryBelow", label: "Summary rows below detail" },
|
|
||||||
Check { key: "summaryRight", label: "Summary columns to right of detail" },
|
|
||||||
],
|
|
||||||
json!({"summaryBelow": below, "summaryRight": right}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
"zoom" => {
|
"zoom" => {
|
||||||
// The current zoom is pre-selected (Custom when it matches no preset). Fit
|
// The current zoom is pre-selected (Custom when it matches no preset). Fit
|
||||||
// selection needs the grid's size, so it is captured here for `view.zoomToSelection`.
|
// selection needs the grid's size, so it is captured here for `view.zoomToSelection`.
|
||||||
@@ -800,12 +786,7 @@ impl Dialog {
|
|||||||
d.tab = if p("replace").as_bool() == Some(true) { "Replace".into() } else { "Find".into() };
|
d.tab = if p("replace").as_bool() == Some(true) { "Replace".into() } else { "Find".into() };
|
||||||
d
|
d
|
||||||
}
|
}
|
||||||
"goTo" => {
|
"goTo" => Dialog::custom("goTo", "Go To", json!({"reference": ""})),
|
||||||
let mut dialog = Dialog::custom("goTo", "Go To", json!({"reference": ""}));
|
|
||||||
// Listed once, when the dialog opens.
|
|
||||||
dialog.result = Some(json!(go_to_entries(app)));
|
|
||||||
dialog
|
|
||||||
}
|
|
||||||
"pivotCalculatedField" => Dialog::form(
|
"pivotCalculatedField" => Dialog::form(
|
||||||
"pivotCalculatedField",
|
"pivotCalculatedField",
|
||||||
"Insert Calculated Field",
|
"Insert Calculated Field",
|
||||||
@@ -843,19 +824,6 @@ impl Dialog {
|
|||||||
Dialog::custom("sort", "Sort", json!({"header": true, "levels": [{"column": col, "order": "asc"}]}))
|
Dialog::custom("sort", "Sort", json!({"header": true, "levels": [{"column": col, "order": "asc"}]}))
|
||||||
}
|
}
|
||||||
"nameManager" => Dialog::custom("nameManager", "Name Manager", json!({})),
|
"nameManager" => Dialog::custom("nameManager", "Name Manager", json!({})),
|
||||||
"pasteName" => {
|
|
||||||
// F3 opens it only when the workbook has names to paste, as in Excel.
|
|
||||||
let names = pasteable_names(app);
|
|
||||||
if p("shortcut") == json!(true) && names.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
// OK runs `formulas.pasteName` with the selected `name`, so `ui.dialog.confirm` does too.
|
|
||||||
let mut dialog = Dialog::custom("pasteName", "Paste Name", json!({"name": names.first()}));
|
|
||||||
dialog.command = Some("formulas.pasteName");
|
|
||||||
// Listed once, when the dialog opens.
|
|
||||||
dialog.result = Some(json!(names));
|
|
||||||
dialog
|
|
||||||
}
|
|
||||||
"manageRules" => Dialog::custom("manageRules", "Conditional Formatting Rules Manager", json!({})),
|
"manageRules" => Dialog::custom("manageRules", "Conditional Formatting Rules Manager", json!({})),
|
||||||
"commandSearch" => Dialog::custom("commandSearch", "Search Commands", json!({})),
|
"commandSearch" => Dialog::custom("commandSearch", "Search Commands", json!({})),
|
||||||
"agents" => Dialog::custom("agents", "Agent Control", json!({})),
|
"agents" => Dialog::custom("agents", "Agent Control", json!({})),
|
||||||
@@ -1296,7 +1264,6 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
|||||||
"zoom" => zoom(ui, &mut d, &mut confirm, &mut open),
|
"zoom" => zoom(ui, &mut d, &mut confirm, &mut open),
|
||||||
"sort" => sort(app, ui, &mut d, &mut confirm),
|
"sort" => sort(app, ui, &mut d, &mut confirm),
|
||||||
"nameManager" => name_manager(app, ui, &mut d, &mut open),
|
"nameManager" => name_manager(app, ui, &mut d, &mut open),
|
||||||
"pasteName" => paste_name(app, ui, &mut d, &mut confirm, &mut open),
|
|
||||||
"manageRules" => manage_rules(app, ui),
|
"manageRules" => manage_rules(app, ui),
|
||||||
"recommendedCharts" => recommended_charts(app, ui, &mut d, &mut confirm, &mut open),
|
"recommendedCharts" => recommended_charts(app, ui, &mut d, &mut confirm, &mut open),
|
||||||
"selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open),
|
"selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open),
|
||||||
@@ -1369,7 +1336,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
|||||||
&& let Some(path) = pick()
|
&& let Some(path) = pick()
|
||||||
{
|
{
|
||||||
d.values.remove("base64");
|
d.values.remove("base64");
|
||||||
let alt = std::path::Path::new(&path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("");
|
let alt = std::path::Path::new(&path).file_name().and_then(|name| name.to_str()).unwrap_or("");
|
||||||
d.values.insert("alt".into(), json!(alt));
|
d.values.insert("alt".into(), json!(alt));
|
||||||
d.values.insert("path".into(), json!(path));
|
d.values.insert("path".into(), json!(path));
|
||||||
}
|
}
|
||||||
@@ -1625,7 +1592,7 @@ fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
|
|||||||
*confirm = true;
|
*confirm = true;
|
||||||
}
|
}
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent));
|
let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE)).fill(t.accent));
|
||||||
if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) {
|
if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) {
|
||||||
*confirm = true;
|
*confirm = true;
|
||||||
}
|
}
|
||||||
@@ -1708,8 +1675,10 @@ fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) {
|
|||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
let lang = crate::i18n::current(ui.ctx());
|
let lang = crate::i18n::current(ui.ctx());
|
||||||
let t = crate::theme::Tokens::get(ui.ctx());
|
let ok = ui.add(
|
||||||
let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent));
|
egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE))
|
||||||
|
.fill(crate::theme::Tokens::get(ui.ctx()).accent),
|
||||||
|
);
|
||||||
if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) {
|
if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) {
|
||||||
*confirm = true;
|
*confirm = true;
|
||||||
}
|
}
|
||||||
@@ -2350,36 +2319,13 @@ fn zoom(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool)
|
|||||||
ok_cancel(ui, confirm, open);
|
ok_cancel(ui, confirm, open);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the Go To dialog lists: the named ranges of every sheet, then the references gone to
|
|
||||||
/// before, newest first.
|
|
||||||
fn go_to_entries(app: &SheetApp) -> Vec<String> {
|
|
||||||
app.session
|
|
||||||
.active()
|
|
||||||
.map(|doc| {
|
|
||||||
let mut v = gridcraft_engine::cmd::edit::go_to_names(&doc.wb, doc.wb.active_sheet);
|
|
||||||
v.extend(doc.go_to_recent.iter().cloned());
|
|
||||||
v
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
|
fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
|
||||||
let entries: Vec<String> =
|
let names: Vec<String> = app.session.active().map(|doc| doc.wb.names.iter().map(|n| n.name.clone()).collect()).unwrap_or_default();
|
||||||
d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default();
|
for n in names {
|
||||||
let current = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string();
|
if ui.selectable_label(false, &n).clicked() {
|
||||||
// A workbook can hold thousands of names: only the rows in view are laid out.
|
d.values.insert("reference".into(), json!(n));
|
||||||
let row_height = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y;
|
|
||||||
egui::ScrollArea::vertical().max_height(160.0).auto_shrink([false, true]).show_rows(ui, row_height, entries.len(), |ui, rows| {
|
|
||||||
for n in entries.get(rows).unwrap_or_default() {
|
|
||||||
let resp = ui.selectable_label(current == *n, n);
|
|
||||||
if resp.clicked() || resp.double_clicked() {
|
|
||||||
d.values.insert("reference".into(), json!(n));
|
|
||||||
}
|
|
||||||
if resp.double_clicked() {
|
|
||||||
*confirm = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
let mut r = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string();
|
let mut r = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string();
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Reference:");
|
ui.label("Reference:");
|
||||||
@@ -2393,7 +2339,7 @@ fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bo
|
|||||||
let mut open = true;
|
let mut open = true;
|
||||||
ok_cancel(ui, confirm, &mut open);
|
ok_cancel(ui, confirm, &mut open);
|
||||||
if *confirm {
|
if *confirm {
|
||||||
app.run_or_alert("edit.goTo", json!({"reference": r, "record": true}));
|
app.run_or_alert("edit.goTo", json!({"reference": r}));
|
||||||
app.grid.ensure_visible = true;
|
app.grid.ensure_visible = true;
|
||||||
}
|
}
|
||||||
if !open {
|
if !open {
|
||||||
@@ -2550,83 +2496,8 @@ fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mu
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The names Paste Names offers in the active workbook.
|
|
||||||
fn pasteable_names(app: &SheetApp) -> Vec<String> {
|
|
||||||
app.session
|
|
||||||
.active()
|
|
||||||
.map(|doc| gridcraft_engine::cmd::formulas::pasteable_names(&doc.wb).into_iter().map(|(n, _)| n).collect())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Puts a defined name into the formula being edited, at the caret. Without an edit under way it
|
|
||||||
/// starts one in the active cell with `=` and the name.
|
|
||||||
pub(crate) fn insert_name(app: &mut SheetApp, name: &str) {
|
|
||||||
match app.editor.as_mut() {
|
|
||||||
Some(ed) => ed.insert_at_caret(name),
|
|
||||||
None => app.begin_edit(Some(format!("={name}")), false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Paste Name (F3, Formulas › Use in Formula › Paste Names…): OK, or a double-click, inserts the
|
|
||||||
/// selected name (`formulas.pasteName` with `name`); Paste List writes every name and what it
|
|
||||||
/// refers to from the active cell.
|
|
||||||
fn paste_name(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
|
||||||
let names: Vec<String> =
|
|
||||||
d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default();
|
|
||||||
// The `name` value is the selection, so `ui.dialog.set` can choose one too.
|
|
||||||
if let Some(i) = d.values.get("name").and_then(Json::as_str).and_then(|n| names.iter().position(|x| x == n)) {
|
|
||||||
d.list_index = i;
|
|
||||||
}
|
|
||||||
let (down, up) = ui.input_mut(|i| (i.consume_key(egui::Modifiers::NONE, Key::ArrowDown), i.consume_key(egui::Modifiers::NONE, Key::ArrowUp)));
|
|
||||||
let last = names.len().saturating_sub(1);
|
|
||||||
if down {
|
|
||||||
d.list_index = (d.list_index + 1).min(last);
|
|
||||||
}
|
|
||||||
if up {
|
|
||||||
d.list_index = d.list_index.saturating_sub(1);
|
|
||||||
}
|
|
||||||
d.list_index = d.list_index.min(last);
|
|
||||||
ui.label(app.ui.language.tr("Paste name"));
|
|
||||||
if names.is_empty() {
|
|
||||||
ui.label(egui::RichText::new(app.ui.language.tr("No names defined")).italics());
|
|
||||||
}
|
|
||||||
// A workbook can hold thousands of names: only the rows in view are laid out.
|
|
||||||
let row_height = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y;
|
|
||||||
let step = row_height + ui.spacing().item_spacing.y;
|
|
||||||
egui::ScrollArea::vertical().max_height(220.0).auto_shrink([false, true]).show_rows(ui, row_height, names.len(), |ui, rows| {
|
|
||||||
let top = ui.cursor().top() - rows.start as f32 * step;
|
|
||||||
for (i, n) in names.iter().enumerate().skip(rows.start).take(rows.len()) {
|
|
||||||
let r = ui.selectable_label(i == d.list_index, n);
|
|
||||||
if r.clicked() {
|
|
||||||
d.list_index = i;
|
|
||||||
}
|
|
||||||
if r.double_clicked() {
|
|
||||||
*confirm = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The arrow keys can move the selection onto a row that isn't laid out.
|
|
||||||
if down || up {
|
|
||||||
let rect = egui::Rect::from_min_size(egui::pos2(ui.min_rect().left(), top + d.list_index as f32 * step), egui::vec2(1.0, row_height));
|
|
||||||
if !ui.clip_rect().contains_rect(rect) {
|
|
||||||
ui.scroll_to_rect(rect, Some(if down { egui::Align::Max } else { egui::Align::Min }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ui.add_space(4.0);
|
|
||||||
// Paste List is disabled while a cell is being edited.
|
|
||||||
if ui.add_enabled(!names.is_empty() && app.editor.is_none(), egui::Button::new(app.ui.language.tr("Paste List"))).clicked() {
|
|
||||||
app.run_or_alert("formulas.pasteName", json!({"list": true}));
|
|
||||||
*open = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Some(n) = names.get(d.list_index) {
|
|
||||||
d.values.insert("name".into(), json!(n));
|
|
||||||
}
|
|
||||||
ok_cancel(ui, confirm, open);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every chart type the All Charts tab offers: `(type, subtype, label)` for `insert.chart`.
|
/// Every chart type the All Charts tab offers: `(type, subtype, label)` for `insert.chart`.
|
||||||
const ALL_CHARTS: [(&str, &str, &str); 21] = [
|
const ALL_CHARTS: [(&str, &str, &str); 20] = [
|
||||||
("column", "", "Clustered Column"),
|
("column", "", "Clustered Column"),
|
||||||
("column", "stacked", "Stacked Column"),
|
("column", "stacked", "Stacked Column"),
|
||||||
("column", "stacked100", "100% Stacked Column"),
|
("column", "stacked100", "100% Stacked Column"),
|
||||||
@@ -2640,7 +2511,6 @@ const ALL_CHARTS: [(&str, &str, &str); 21] = [
|
|||||||
("doughnut", "", "Doughnut"),
|
("doughnut", "", "Doughnut"),
|
||||||
("area", "", "Area"),
|
("area", "", "Area"),
|
||||||
("area", "stacked", "Stacked Area"),
|
("area", "stacked", "Stacked Area"),
|
||||||
("area", "stacked100", "100% Stacked Area"),
|
|
||||||
("scatter", "", "Scatter"),
|
("scatter", "", "Scatter"),
|
||||||
("scatter", "lines", "Scatter with Lines"),
|
("scatter", "lines", "Scatter with Lines"),
|
||||||
("radar", "", "Radar"),
|
("radar", "", "Radar"),
|
||||||
@@ -3196,8 +3066,8 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool
|
|||||||
);
|
);
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
let t = crate::theme::Tokens::get(ui.ctx());
|
let solve =
|
||||||
let solve = ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(t.on_accent)).fill(t.accent));
|
ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(Color32::WHITE)).fill(crate::theme::Tokens::get(ui.ctx()).accent));
|
||||||
if solve.clicked() && !text(d, "objective").trim().is_empty() {
|
if solve.clicked() && !text(d, "objective").trim().is_empty() {
|
||||||
*confirm = true;
|
*confirm = true;
|
||||||
}
|
}
|
||||||
@@ -3272,7 +3142,7 @@ fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bo
|
|||||||
let word = it["word"].as_str().unwrap_or("").to_string();
|
let word = it["word"].as_str().unwrap_or("").to_string();
|
||||||
let cell = it["cell"].as_str().unwrap_or("").to_string();
|
let cell = it["cell"].as_str().unwrap_or("").to_string();
|
||||||
ui.label(format!("Not in Dictionary ({cell}):"));
|
ui.label(format!("Not in Dictionary ({cell}):"));
|
||||||
ui.label(egui::RichText::new(&word).strong().color(crate::theme::Tokens::get(ui.ctx()).danger));
|
ui.label(egui::RichText::new(&word).strong().color(Color32::from_rgb(0xC4, 0x2B, 0x1C)));
|
||||||
let sugg: Vec<String> =
|
let sugg: Vec<String> =
|
||||||
it["suggestions"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
it["suggestions"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||||||
ui.label("Suggestions:");
|
ui.label("Suggestions:");
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
//! In-cell / formula-bar editing state: point mode, reference colouring, function
|
//! In-cell / formula-bar editing state: point mode, reference colouring, function
|
||||||
//! autocomplete and argument hints.
|
//! autocomplete and argument hints.
|
||||||
|
|
||||||
|
use egui::Color32;
|
||||||
use gridcraft_engine::core::{CellRef, RangeRef};
|
use gridcraft_engine::core::{CellRef, RangeRef};
|
||||||
|
|
||||||
/// How many distinct reference colours a formula cycles through (`Tokens::ref_colors`).
|
/// Colours for references in a formula being edited (our own palette).
|
||||||
pub const REF_COLOR_COUNT: usize = 8;
|
pub const REF_COLORS: [Color32; 8] = [
|
||||||
|
Color32::from_rgb(0x2E, 0x6F, 0xD8),
|
||||||
|
Color32::from_rgb(0xD0, 0x3C, 0x3C),
|
||||||
|
Color32::from_rgb(0x7A, 0x4F, 0xC9),
|
||||||
|
Color32::from_rgb(0x2B, 0x9A, 0x4A),
|
||||||
|
Color32::from_rgb(0xC2, 0x5E, 0x1E),
|
||||||
|
Color32::from_rgb(0x1D, 0x92, 0xA6),
|
||||||
|
Color32::from_rgb(0xB8, 0x3B, 0x8E),
|
||||||
|
Color32::from_rgb(0x8A, 0x7A, 0x1F),
|
||||||
|
];
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct EditState {
|
pub struct EditState {
|
||||||
@@ -18,9 +28,6 @@ pub struct EditState {
|
|||||||
pub from_formula_bar: bool,
|
pub from_formula_bar: bool,
|
||||||
/// Caret position (char index), mirrored from the text widget.
|
/// Caret position (char index), mirrored from the text widget.
|
||||||
pub caret: usize,
|
pub caret: usize,
|
||||||
/// The other end of the selected text (char index), mirrored like `caret`; equal to `caret`
|
|
||||||
/// when nothing is selected.
|
|
||||||
pub anchor: usize,
|
|
||||||
/// The reference most recently inserted by point mode (byte range) — arrows/clicks replace it.
|
/// The reference most recently inserted by point mode (byte range) — arrows/clicks replace it.
|
||||||
pub point: Option<(usize, usize)>,
|
pub point: Option<(usize, usize)>,
|
||||||
/// Where point mode's keyboard cursor is.
|
/// Where point mode's keyboard cursor is.
|
||||||
@@ -32,9 +39,6 @@ pub struct EditState {
|
|||||||
pub request_focus: bool,
|
pub request_focus: bool,
|
||||||
/// The text changed programmatically: push `caret` into the widget on the next frame.
|
/// The text changed programmatically: push `caret` into the widget on the next frame.
|
||||||
pub sync_caret: bool,
|
pub sync_caret: bool,
|
||||||
/// A dialog opened over the edit has the keyboard: when the editor takes it back without a
|
|
||||||
/// change to the caret, the selected text (`anchor`..`caret`) stays selected.
|
|
||||||
pub keep_selection: bool,
|
|
||||||
/// Column AutoComplete: the full entry the typed text completes to.
|
/// Column AutoComplete: the full entry the typed text completes to.
|
||||||
pub completion: Option<String>,
|
pub completion: Option<String>,
|
||||||
/// Move the caret to the end on the next frame.
|
/// Move the caret to the end on the next frame.
|
||||||
@@ -52,7 +56,6 @@ impl EditState {
|
|||||||
enter_mode,
|
enter_mode,
|
||||||
from_formula_bar,
|
from_formula_bar,
|
||||||
caret,
|
caret,
|
||||||
anchor: caret,
|
|
||||||
point: None,
|
point: None,
|
||||||
point_cell: None,
|
point_cell: None,
|
||||||
point_anchor: None,
|
point_anchor: None,
|
||||||
@@ -60,7 +63,6 @@ impl EditState {
|
|||||||
ac_index: 0,
|
ac_index: 0,
|
||||||
request_focus: true,
|
request_focus: true,
|
||||||
sync_caret: true,
|
sync_caret: true,
|
||||||
keep_selection: false,
|
|
||||||
caret_to_end: true,
|
caret_to_end: true,
|
||||||
completion: None,
|
completion: None,
|
||||||
}
|
}
|
||||||
@@ -110,27 +112,6 @@ impl EditState {
|
|||||||
self.sync_caret = true;
|
self.sync_caret = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inserts `t` at the caret as typed text (Paste Names), in place of the selected text: the
|
|
||||||
/// caret ends after it, and point mode starts afresh.
|
|
||||||
pub fn insert_at_caret(&mut self, t: &str) {
|
|
||||||
// When code moved the caret this frame (`sync_caret`), the widget has not yet stored it, so `anchor` is out of
|
|
||||||
// date and the name goes in at the caret.
|
|
||||||
let (from, to) = if self.sync_caret { (self.caret, self.caret) } else { (self.anchor.min(self.caret), self.anchor.max(self.caret)) };
|
|
||||||
let byte = |c: usize| self.text.char_indices().nth(c).map(|(i, _)| i).unwrap_or(self.text.len());
|
|
||||||
let (Some(a), Some(b)) = (self.text.get(..byte(from)), self.text.get(byte(to)..)) else { return };
|
|
||||||
self.text = format!("{a}{t}{b}");
|
|
||||||
self.point = None;
|
|
||||||
self.caret = from + t.chars().count();
|
|
||||||
self.anchor = self.caret;
|
|
||||||
// A function suggestion shown before the name went in no longer fits the text.
|
|
||||||
self.autocomplete.clear();
|
|
||||||
self.ac_index = 0;
|
|
||||||
self.completion = None;
|
|
||||||
self.caret_to_end = false;
|
|
||||||
self.sync_caret = true;
|
|
||||||
self.request_focus = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add another argument after the current pointed reference. Keep only the new reference
|
/// Add another argument after the current pointed reference. Keep only the new reference
|
||||||
/// replaceable, so a subsequent plain click does not remove earlier arguments or the separator.
|
/// replaceable, so a subsequent plain click does not remove earlier arguments or the separator.
|
||||||
pub fn append_ref(&mut self, r: &str) {
|
pub fn append_ref(&mut self, r: &str) {
|
||||||
@@ -312,7 +293,7 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option<String>,
|
|||||||
seen.len() - 1
|
seen.len() - 1
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
out.push((start + offset, end + offset, range, sheet, idx % REF_COLOR_COUNT));
|
out.push((start + offset, end + offset, range, sheet, idx % REF_COLORS.len()));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
@@ -398,21 +379,6 @@ mod tests {
|
|||||||
assert!(!e2.can_point());
|
assert!(!e2.can_point());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_pasted_name_replaces_the_selected_text() {
|
|
||||||
let mut e = EditState::new(0, CellRef::new(0, 0), "=OldName+1".into(), false, false);
|
|
||||||
e.sync_caret = false;
|
|
||||||
(e.anchor, e.caret) = (8, 1);
|
|
||||||
e.autocomplete = vec!["SUM".into()];
|
|
||||||
e.insert_at_caret("Sales");
|
|
||||||
assert_eq!((e.text.as_str(), e.caret, e.anchor), ("=Sales+1", 6, 6));
|
|
||||||
assert!(e.autocomplete.is_empty(), "the earlier function suggestion is gone");
|
|
||||||
// Nothing selected: the name goes in at the caret.
|
|
||||||
e.sync_caret = false;
|
|
||||||
e.insert_at_caret("Rate");
|
|
||||||
assert_eq!((e.text.as_str(), e.caret), ("=SalesRate+1", 10));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn autocomplete_and_hints() {
|
fn autocomplete_and_hints() {
|
||||||
let names: Vec<String> = ["SUM", "SUMIF", "SUMPRODUCT", "AVERAGE"].iter().map(|s| s.to_string()).collect();
|
let names: Vec<String> = ["SUM", "SUMIF", "SUMPRODUCT", "AVERAGE"].iter().map(|s| s.to_string()).collect();
|
||||||
|
|||||||
@@ -95,13 +95,6 @@ impl Cache {
|
|||||||
}
|
}
|
||||||
None => (None, u32::MAX),
|
None => (None, u32::MAX),
|
||||||
};
|
};
|
||||||
// Eviction only frees entries used before this frame, so a workbook covered in same-frame
|
|
||||||
// pictures can leave the cache unable to make room. Then draw the texture without
|
|
||||||
// retaining it: the handle is dropped at the end of the frame and the GPU memory goes
|
|
||||||
// with it, instead of the budget being outflanked (finding: cache admission).
|
|
||||||
if self.entries.len() >= MAX_CACHE_ITEMS || self.bytes.saturating_add(bytes) > MAX_CACHE_BYTES {
|
|
||||||
return texture;
|
|
||||||
}
|
|
||||||
self.bytes = self.bytes.saturating_add(bytes);
|
self.bytes = self.bytes.saturating_add(bytes);
|
||||||
self.entries.insert(key, Entry { texture: texture.clone(), bytes, side, used: frame });
|
self.entries.insert(key, Entry { texture: texture.clone(), bytes, side, used: frame });
|
||||||
texture
|
texture
|
||||||
@@ -219,24 +212,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert!(cache.bytes <= MAX_CACHE_BYTES, "{} bytes kept", cache.bytes);
|
assert!(cache.bytes <= MAX_CACHE_BYTES, "{} bytes kept", cache.bytes);
|
||||||
assert!(cache.entries.len() < data.len());
|
assert!(cache.entries.len() < data.len());
|
||||||
// Many on screen at once, over the budget: each still gets a texture this frame, but the
|
// Many on screen at once, over the budget: every one keeps its texture, frame after frame.
|
||||||
// cache cannot grow past its budget whatever the workbook shows. Eight of the 16 MB
|
|
||||||
// textures fit the 128 MB budget, and those stay pinned; the rest re-upload each frame
|
|
||||||
// instead of being retained over it (finding: cache admission).
|
|
||||||
// Two frames pass so the rehearsal's entries are old enough to be evictable.
|
|
||||||
frame(&ctx);
|
|
||||||
frame(&ctx);
|
|
||||||
let shown: Vec<_> = data.iter().enumerate().take(12).map(|(i, d)| cache.texture(&ctx, key(1, i as u32, d), d, 2048).unwrap().id()).collect();
|
let shown: Vec<_> = data.iter().enumerate().take(12).map(|(i, d)| cache.texture(&ctx, key(1, i as u32, d), d, 2048).unwrap().id()).collect();
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
frame(&ctx);
|
frame(&ctx);
|
||||||
for (i, d) in data.iter().enumerate().take(12) {
|
for (i, d) in data.iter().enumerate().take(12) {
|
||||||
let id = cache.texture(&ctx, key(1, i as u32, d), d, 2048).unwrap().id();
|
assert_eq!(cache.texture(&ctx, key(1, i as u32, d), d, 2048).map(|t| t.id()), Some(shown[i]));
|
||||||
if i < 8 {
|
|
||||||
assert_eq!(id, shown[i], "a picture that fits keeps its texture");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
assert!(cache.bytes <= MAX_CACHE_BYTES, "{} bytes kept", cache.bytes);
|
|
||||||
assert!(cache.entries.len() <= 8, "{} entries kept", cache.entries.len());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
|
|||||||
/// Weight implied by a style name (`"Bold"` → 700, `"Light"` → 300).
|
/// Weight implied by a style name (`"Bold"` → 700, `"Light"` → 300).
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn style_weight(style: &str) -> f32 {
|
fn style_weight(style: &str) -> f32 {
|
||||||
let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect();
|
let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect();
|
||||||
const TABLE: &[(&str, f32)] = &[
|
const TABLE: &[(&str, f32)] = &[
|
||||||
("hairline", 100.0),
|
("hairline", 100.0),
|
||||||
("thin", 100.0),
|
("thin", 100.0),
|
||||||
@@ -107,7 +107,7 @@ impl Face {
|
|||||||
/// Whether a style name reads as italic or oblique.
|
/// Whether a style name reads as italic or oblique.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn style_italic(style: &str) -> bool {
|
fn style_italic(style: &str) -> bool {
|
||||||
let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect();
|
let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect();
|
||||||
s.contains("italic") || s.contains("oblique")
|
s.contains("italic") || s.contains("oblique")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,10 +165,8 @@ pub fn scan_dirs(dirs: &[PathBuf]) -> Vec<Face> {
|
|||||||
stack.push(path);
|
stack.push(path);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let is_font = path
|
let is_font =
|
||||||
.extension()
|
path.extension().and_then(|e| e.to_str()).is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc"));
|
||||||
.and_then(std::ffi::OsStr::to_str)
|
|
||||||
.is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc"));
|
|
||||||
if !is_font || std::fs::metadata(&path).map(|m| m.len() > MAX_FONT_FILE).unwrap_or(true) {
|
if !is_font || std::fs::metadata(&path).map(|m| m.len() > MAX_FONT_FILE).unwrap_or(true) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -368,12 +366,12 @@ fn bound() -> &'static std::sync::RwLock<HashSet<String>> {
|
|||||||
|
|
||||||
/// Record the role families a freshly built [`egui::FontDefinitions`] bound.
|
/// Record the role families a freshly built [`egui::FontDefinitions`] bound.
|
||||||
pub fn set_bound(keys: impl IntoIterator<Item = String>) {
|
pub fn set_bound(keys: impl IntoIterator<Item = String>) {
|
||||||
*bound().write().unwrap_or_else(std::sync::PoisonError::into_inner) = keys.into_iter().collect();
|
*bound().write().unwrap_or_else(|e| e.into_inner()) = keys.into_iter().collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Is this exact role family bound to font data?
|
/// Is this exact role family bound to font data?
|
||||||
pub fn is_bound(role: &str) -> bool {
|
pub fn is_bound(role: &str) -> bool {
|
||||||
bound().read().unwrap_or_else(std::sync::PoisonError::into_inner).contains(role)
|
bound().read().unwrap_or_else(|e| e.into_inner()).contains(role)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serializes tests that mutate the process-wide bound-family registry.
|
/// Serializes tests that mutate the process-wide bound-family registry.
|
||||||
|
|||||||
@@ -1,149 +1,65 @@
|
|||||||
//! The formula bar (Name Box, ✕ ✓ fx, formula field) and the shared cell editor widget.
|
//! The formula bar (Name Box, ✕ ✓ fx, formula field) and the shared cell editor widget.
|
||||||
|
|
||||||
use egui::{FontId, Key, Rect, Sense, Stroke, StrokeKind, TextEdit, pos2, vec2};
|
use egui::{Color32, FontId, Key, Rect, Sense, Stroke, StrokeKind, TextEdit, pos2, vec2};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::SheetApp;
|
use crate::SheetApp;
|
||||||
use crate::editor::formula_refs;
|
use crate::editor::{REF_COLORS, formula_refs};
|
||||||
use crate::icons::{self, Icon};
|
use crate::icons::{self, Icon};
|
||||||
use crate::theme::{self, Tokens};
|
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 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)
|
|
||||||
/// and two pixels for the rounding to the pixel grid.
|
|
||||||
const PAD: f32 = 14.0;
|
|
||||||
|
|
||||||
/// The bar's height for one line of a font whose rows are `row_h` tall: Excel's 30 px, unless
|
|
||||||
/// the rows need more (the Windows UI font's row is 17.28 px). Every line counted must fit in
|
|
||||||
/// the field, whatever the font; `n` lines take `one_line(row_h) + (n - 1) * row_h`.
|
|
||||||
pub fn one_line(row_h: f32) -> f32 {
|
|
||||||
ONE_LINE.max(row_h + PAD)
|
|
||||||
}
|
|
||||||
/// How long the bar takes to grow or shrink to a new line count.
|
|
||||||
const EASE_SECONDS: f32 = 0.12;
|
|
||||||
|
|
||||||
/// Where the formula bar put its parts last frame (for tests).
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
||||||
pub struct BarLayout {
|
|
||||||
pub name_box: Rect,
|
|
||||||
/// ✕ ✓ fx.
|
|
||||||
pub buttons: Rect,
|
|
||||||
pub field: Rect,
|
|
||||||
pub height: f32,
|
|
||||||
/// Laid-out lines of the content (wrapped to the field), and how many show without scrolling.
|
|
||||||
pub lines: usize,
|
|
||||||
pub visible_lines: usize,
|
|
||||||
/// The caret while editing in the bar.
|
|
||||||
pub caret: Option<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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BarLayout {
|
|
||||||
pub fn id() -> egui::Id {
|
|
||||||
egui::Id::new("formula_bar.layout")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Last frame's layout.
|
|
||||||
pub fn get(ctx: &egui::Context) -> BarLayout {
|
|
||||||
ctx.data(|d| d.get_temp(Self::id())).unwrap_or_default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What the editor widget laid out: its line count and caret.
|
|
||||||
#[derive(Clone, Copy, Debug, Default)]
|
|
||||||
pub struct EditorInfo {
|
|
||||||
pub lines: usize,
|
|
||||||
pub caret: Option<Rect>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||||
let t = Tokens::get(ui.ctx());
|
let t = Tokens::get(ui.ctx());
|
||||||
let font = theme::ui_font(13.0);
|
let expanded = app.ui.formula_bar_expanded;
|
||||||
let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font));
|
let h = if expanded { 72.0 } else { 30.0 };
|
||||||
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) };
|
|
||||||
let target = one_line(row_h) + (lines - 1) as f32 * row_h;
|
|
||||||
let h = ui.ctx().animate_value_with_time(egui::Id::new("formula_bar.height"), target, EASE_SECONDS);
|
|
||||||
let mut layout = BarLayout { height: h, ..Default::default() };
|
|
||||||
egui::Panel::top("formula_bar")
|
egui::Panel::top("formula_bar")
|
||||||
.exact_size(h)
|
.exact_size(h)
|
||||||
.frame(egui::Frame::NONE.fill(t.window).inner_margin(egui::Margin { left: 8, right: 8, top: 3, bottom: 3 }))
|
.frame(egui::Frame::NONE.fill(t.window).inner_margin(egui::Margin { left: 8, right: 8, top: 3, bottom: 3 }))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
// Top-aligned: the Name Box and buttons stay on the first line as the field grows.
|
ui.horizontal(|ui| {
|
||||||
ui.with_layout(egui::Layout::left_to_right(egui::Align::Min), |ui| {
|
|
||||||
ui.spacing_mut().item_spacing.x = 4.0;
|
ui.spacing_mut().item_spacing.x = 4.0;
|
||||||
layout.name_box = name_box(app, ui, &t);
|
name_box(app, ui, &t);
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
let editing = app.editor.is_some();
|
let editing = app.editor.is_some();
|
||||||
let c = if editing { t.text } else { t.text_disabled };
|
let c = if editing { t.text } else { t.text_disabled };
|
||||||
let cancel = crate::widgets::icon_button(ui, Icon::Close, c, "Cancel", vec2(22.0, 24.0));
|
if crate::widgets::icon_button(ui, Icon::Close, c, "Cancel", vec2(22.0, 22.0)).clicked() && editing {
|
||||||
if cancel.clicked() && editing {
|
|
||||||
app.cancel_edit();
|
app.cancel_edit();
|
||||||
}
|
}
|
||||||
if crate::widgets::icon_button(ui, Icon::Check, c, "Enter", vec2(22.0, 24.0)).clicked() && editing {
|
if crate::widgets::icon_button(ui, Icon::Check, c, "Enter", vec2(22.0, 22.0)).clicked() && editing {
|
||||||
app.commit_edit(0, 0, false, false);
|
app.commit_edit(0, 0, false, false);
|
||||||
}
|
}
|
||||||
let fx = crate::widgets::icon_button(ui, Icon::Fx, t.text, "Insert Function", vec2(26.0, 24.0));
|
if crate::widgets::icon_button(ui, Icon::Fx, t.text, "Insert Function", vec2(26.0, 22.0)).clicked() {
|
||||||
if fx.clicked() {
|
|
||||||
app.open_dialog("insertFunction", json!({}));
|
app.open_dialog("insertFunction", json!({}));
|
||||||
}
|
}
|
||||||
layout.buttons = cancel.rect.union(fx.rect);
|
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
// The formula field fills the rest, as tall as the bar.
|
// The formula field.
|
||||||
let avail = ui.available_width() - 24.0;
|
let avail = ui.available_width() - 24.0;
|
||||||
let field = Rect::from_min_size(ui.cursor().min, vec2(avail, ui.available_height()));
|
let field = Rect::from_min_size(ui.cursor().min, vec2(avail, h - 6.0));
|
||||||
ui.painter().rect_filled(field, 4.0, t.input_bg);
|
ui.painter().rect_filled(field, 4.0, t.input_bg);
|
||||||
ui.painter().rect_stroke(field, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
|
ui.painter().rect_stroke(field, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
|
||||||
let inner = field.shrink2(vec2(6.0, 3.0));
|
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field.shrink2(vec2(6.0, 3.0))));
|
||||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner));
|
|
||||||
let id = egui::Id::new("gridcraft.formula_bar");
|
let id = egui::Id::new("gridcraft.formula_bar");
|
||||||
// A spill child shows its anchor's formula muted and read-only.
|
|
||||||
let read_only = active_spill_anchor(app).is_some();
|
let read_only = active_spill_anchor(app).is_some();
|
||||||
let info = if app.editor.is_some() {
|
if app.editor.is_some() {
|
||||||
editor_widget(app, &mut child, id, font.clone(), true, inner.width(), inner.height())
|
editor_widget(app, &mut child, id, theme::ui_font(13.0), expanded, field.width() - 12.0);
|
||||||
} else {
|
} else {
|
||||||
// Show the active cell's content; clicking starts editing in the bar.
|
// Show the active cell's content; clicking starts editing in the bar.
|
||||||
let text = active_input(app);
|
let text = active_input(app);
|
||||||
let color = if read_only { t.text_disabled } else { t.text };
|
let color = if read_only { t.text_disabled } else { t.text };
|
||||||
let galley = child.painter().layout(text, font.clone(), color, inner.width());
|
let resp = child
|
||||||
let lines = galley.rows.len();
|
.add(egui::Label::new(egui::RichText::new(text).font(theme::ui_font(13.0)).color(color)).truncate().sense(Sense::click()));
|
||||||
// Each cell's view starts at its top: the offset is kept per cell, not per bar.
|
let click = child.interact(field, id.with("click"), Sense::click());
|
||||||
let shown = app.session.active().map(|d| (d.uid, d.wb.active_sheet, d.selection.active.row, d.selection.active.col));
|
if !read_only && (resp.clicked() || click.clicked()) {
|
||||||
egui::ScrollArea::vertical().id_salt(id.with(("view", shown))).max_height(inner.height()).auto_shrink([false, true]).show(
|
|
||||||
&mut child,
|
|
||||||
|ui| {
|
|
||||||
if ui.add(egui::Label::new(galley).sense(Sense::click())).clicked() && !read_only {
|
|
||||||
app.begin_edit(None, true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if !read_only && ui.interact(field, id.with("click"), Sense::click()).clicked() {
|
|
||||||
app.begin_edit(None, true);
|
app.begin_edit(None, true);
|
||||||
}
|
}
|
||||||
EditorInfo { lines, caret: None }
|
}
|
||||||
};
|
ui.allocate_space(vec2(avail, h - 6.0));
|
||||||
layout.field = field;
|
let chevron = if expanded { Icon::ChevronUp } else { Icon::Chevron };
|
||||||
layout.lines = info.lines.max(1);
|
if crate::widgets::icon_button(ui, chevron, t.text_dim, "Expand formula bar", vec2(20.0, 22.0)).clicked() {
|
||||||
layout.visible_lines = ((inner.height() / row_h).floor() as usize).max(1);
|
|
||||||
layout.caret = info.caret;
|
|
||||||
ui.allocate_space(vec2(avail, field.height()));
|
|
||||||
let chevron = if app.ui.formula_bar_expanded { Icon::ChevronUp } else { Icon::Chevron };
|
|
||||||
if crate::widgets::icon_button(ui, chevron, t.text_dim, "Expand formula bar", vec2(20.0, 24.0)).clicked() {
|
|
||||||
app.ui.formula_bar_expanded = !app.ui.formula_bar_expanded;
|
app.ui.formula_bar_expanded = !app.ui.formula_bar_expanded;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
ui.ctx().data_mut(|d| d.insert_temp(BarLayout::id(), layout));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve ownership on every frame; never cache a spill range in presentation state.
|
/// Resolve ownership on every frame; never cache a spill range in presentation state.
|
||||||
@@ -185,7 +101,7 @@ fn active_input(app: &SheetApp) -> String {
|
|||||||
String::new()
|
String::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) -> Rect {
|
fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||||
let id = egui::Id::new("gridcraft.name_box");
|
let id = egui::Id::new("gridcraft.name_box");
|
||||||
let shown = app.name_box_text();
|
let shown = app.name_box_text();
|
||||||
let mut text = app.name_box.clone().unwrap_or(shown.clone());
|
let mut text = app.name_box.clone().unwrap_or(shown.clone());
|
||||||
@@ -242,7 +158,6 @@ fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) -> Rect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
rect
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backspace and Delete take back the AutoComplete proposal (Excel's selected rest), not a typed character.
|
/// Backspace and Delete take back the AutoComplete proposal (Excel's selected rest), not a typed character.
|
||||||
@@ -257,33 +172,9 @@ fn entry_shrank(chars_before: usize, text: &str) -> bool {
|
|||||||
text.chars().count() < chars_before
|
text.chars().count() < chars_before
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The editor's text field, the same in the cell and in the formula bar. Enter is the editor's
|
/// The text editor used in the cell and in the formula bar (they share `app.editor`).
|
||||||
/// own key (it commits, and Alt+Enter breaks the line), so the field itself does nothing with it:
|
pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32) {
|
||||||
/// a message box over the editor takes Enter to close, and the field must not add a line then.
|
let Some(mut ed) = app.editor.take() else { return };
|
||||||
fn show_edit<'t>(
|
|
||||||
edit: TextEdit<'t>,
|
|
||||||
ui: &mut egui::Ui,
|
|
||||||
id: egui::Id,
|
|
||||||
font: FontId,
|
|
||||||
width: f32,
|
|
||||||
layouter: &'t mut dyn FnMut(&egui::Ui, &dyn egui::TextBuffer, f32) -> std::sync::Arc<egui::Galley>,
|
|
||||||
) -> egui::text_edit::TextEditOutput {
|
|
||||||
edit.id(id)
|
|
||||||
.frame(egui::Frame::NONE)
|
|
||||||
.font(font)
|
|
||||||
.desired_width(width)
|
|
||||||
.desired_rows(1)
|
|
||||||
.layouter(layouter)
|
|
||||||
.lock_focus(true)
|
|
||||||
.return_key(None)
|
|
||||||
.event_filter(egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })
|
|
||||||
.show(ui)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The text editor used in the cell and in the formula bar (they share `app.editor`). It shows
|
|
||||||
/// `max_height` of text and scrolls beyond that, keeping the caret in view.
|
|
||||||
pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32, max_height: f32) -> EditorInfo {
|
|
||||||
let Some(mut ed) = app.editor.take() else { return EditorInfo::default() };
|
|
||||||
let mine = ed.from_formula_bar == (id == egui::Id::new("gridcraft.formula_bar"));
|
let mine = ed.from_formula_bar == (id == egui::Id::new("gridcraft.formula_bar"));
|
||||||
// Keys the editor handles itself (before the TextEdit sees them), only where it has focus.
|
// Keys the editor handles itself (before the TextEdit sees them), only where it has focus.
|
||||||
let focused = ui.ctx().memory(|m| m.focused());
|
let focused = ui.ctx().memory(|m| m.focused());
|
||||||
@@ -293,8 +184,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
|||||||
let mut cancel = false;
|
let mut cancel = false;
|
||||||
// A message box over the editor (a data validation alert, a formula error) gets Enter and Esc;
|
// A message box over the editor (a data validation alert, a formula error) gets Enter and Esc;
|
||||||
// the editor used to take them, so the first Esc cancelled the edit behind the message.
|
// the editor used to take them, so the first Esc cancelled the edit behind the message.
|
||||||
// So does a dialog opened from the editor (Paste Name): its OK takes Enter.
|
if has_focus && mine && app.message.is_none() {
|
||||||
if has_focus && mine && app.message.is_none() && app.dialog.is_none() {
|
|
||||||
let (enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, shift) = ui.input_mut(|i| {
|
let (enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, shift) = ui.input_mut(|i| {
|
||||||
let m = i.modifiers;
|
let m = i.modifiers;
|
||||||
let alt_enter =
|
let alt_enter =
|
||||||
@@ -384,10 +274,6 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
|||||||
if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::F4)) {
|
if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::F4)) {
|
||||||
cycle_anchor(&mut ed);
|
cycle_anchor(&mut ed);
|
||||||
}
|
}
|
||||||
// F3 opens Paste Name when the workbook has names; OK puts the name at the caret.
|
|
||||||
if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::F3)) {
|
|
||||||
app.open_dialog("pasteName", json!({"shortcut": true}));
|
|
||||||
}
|
|
||||||
// Ctrl+Shift+A (Control+Shift+A on a Mac) inserts the argument names. Consumed anywhere,
|
// Ctrl+Shift+A (Control+Shift+A on a Mac) inserts the argument names. Consumed anywhere,
|
||||||
// as in Excel, so it never falls through to the text's select-all.
|
// as in Excel, so it never falls through to the text's select-all.
|
||||||
if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, Key::A)) {
|
if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, Key::A)) {
|
||||||
@@ -404,16 +290,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
|||||||
state.store(ui.ctx(), id);
|
state.store(ui.ctx(), id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Ctrl+Shift+U (Control+Shift+U on a Mac) expands or collapses the formula bar while editing too.
|
|
||||||
if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, Key::U)) {
|
|
||||||
app.run_or_alert("view.expandFormulaBar", json!({}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// The text widget.
|
// The text widget.
|
||||||
let refs = formula_refs(&ed.text);
|
let refs = formula_refs(&ed.text);
|
||||||
let t = Tokens::get(ui.ctx());
|
let text_color = Tokens::get(ui.ctx()).text;
|
||||||
let on_sheet = id == egui::Id::new("gridcraft.cell_editor");
|
let cell_text_color = if id == egui::Id::new("gridcraft.cell_editor") { Color32::BLACK } else { text_color };
|
||||||
let cell_text_color = if on_sheet { t.cell_text } else { t.text };
|
|
||||||
let font2 = font.clone();
|
let font2 = font.clone();
|
||||||
let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
|
let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
|
||||||
let s = buf.as_str();
|
let s = buf.as_str();
|
||||||
@@ -426,7 +307,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
job.append(s.get(pos..a).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), cell_text_color));
|
job.append(s.get(pos..a).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), cell_text_color));
|
||||||
job.append(s.get(a..b).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), t.ref_color(idx, on_sheet)));
|
job.append(s.get(a..b).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), REF_COLORS[idx]));
|
||||||
pos = b;
|
pos = b;
|
||||||
}
|
}
|
||||||
job.append(s.get(pos..).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), cell_text_color));
|
job.append(s.get(pos..).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), cell_text_color));
|
||||||
@@ -434,55 +315,32 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
|||||||
ui.fonts_mut(|f| f.layout_job(job))
|
ui.fonts_mut(|f| f.layout_job(job))
|
||||||
};
|
};
|
||||||
let chars_before = ed.text.chars().count();
|
let chars_before = ed.text.chars().count();
|
||||||
// A dialog opened over the edit (Paste Name) takes the keyboard until it closes, and the
|
// The in-cell editor is as tall as its lines (a multiline field reserves four rows).
|
||||||
// editor takes it back afterwards.
|
let in_cell = id == egui::Id::new("gridcraft.cell_editor");
|
||||||
let dialog_open = app.dialog.is_some();
|
let edit = if multiline || ed.text.contains('\n') {
|
||||||
if dialog_open && mine {
|
TextEdit::multiline(&mut ed.text).desired_rows(if in_cell { 1 } else { 4 })
|
||||||
ed.request_focus = true;
|
|
||||||
ed.keep_selection = true;
|
|
||||||
}
|
|
||||||
let synced = mine && !dialog_open && (ed.request_focus || ed.sync_caret);
|
|
||||||
let edit = if multiline || ed.text.contains('\n') { TextEdit::multiline(&mut ed.text) } else { TextEdit::singleline(&mut ed.text) };
|
|
||||||
let edit = edit.interactive(!dialog_open);
|
|
||||||
let caret_index = ed.caret;
|
|
||||||
// The in-cell editor grows with its text and never scrolls, so the field keeps its whole width
|
|
||||||
// for the text (a scroll area would take the bar's width off it and wrap the text narrower
|
|
||||||
// than the cell does). The formula bar shows its lines and scrolls past them.
|
|
||||||
let mut output = if on_sheet {
|
|
||||||
show_edit(edit, ui, id, font, width, &mut layouter)
|
|
||||||
} else {
|
} else {
|
||||||
egui::ScrollArea::vertical()
|
TextEdit::singleline(&mut ed.text)
|
||||||
.id_salt(id.with("scroll"))
|
|
||||||
.max_height(max_height.max(1.0))
|
|
||||||
.auto_shrink([false, true])
|
|
||||||
.show(ui, |ui| {
|
|
||||||
let output = show_edit(edit, ui, id, font, width, &mut layouter);
|
|
||||||
// A caret moved by our own keys (Alt+Enter, F4, point mode) isn't the TextEdit's
|
|
||||||
// doing, so it doesn't scroll to it itself.
|
|
||||||
if synced {
|
|
||||||
let caret = output.galley.pos_from_cursor(egui::text::CCursor::new(caret_index)).translate(output.galley_pos.to_vec2());
|
|
||||||
ui.scroll_to_rect(caret.expand(2.0), None);
|
|
||||||
}
|
|
||||||
output
|
|
||||||
})
|
|
||||||
.inner
|
|
||||||
};
|
};
|
||||||
|
let mut output = edit
|
||||||
|
.id(id)
|
||||||
|
.frame(egui::Frame::NONE)
|
||||||
|
.font(font)
|
||||||
|
.desired_width(width)
|
||||||
|
.layouter(&mut layouter)
|
||||||
|
.lock_focus(true)
|
||||||
|
.event_filter(egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })
|
||||||
|
.show(ui);
|
||||||
let resp = output.response.clone();
|
let resp = output.response.clone();
|
||||||
|
let synced = mine && (ed.request_focus || ed.sync_caret);
|
||||||
if synced {
|
if synced {
|
||||||
if ed.request_focus {
|
if ed.request_focus {
|
||||||
resp.request_focus();
|
resp.request_focus();
|
||||||
}
|
}
|
||||||
ed.request_focus = false;
|
ed.request_focus = false;
|
||||||
// Closing a dialog that changed nothing (Cancel) gives the selection back as it was.
|
|
||||||
let range = if ed.keep_selection && !ed.sync_caret {
|
|
||||||
egui::text::CCursorRange::two(egui::text::CCursor::new(ed.anchor), egui::text::CCursor::new(ed.caret))
|
|
||||||
} else {
|
|
||||||
ed.anchor = ed.caret;
|
|
||||||
egui::text::CCursorRange::one(egui::text::CCursor::new(ed.caret))
|
|
||||||
};
|
|
||||||
ed.sync_caret = false;
|
ed.sync_caret = false;
|
||||||
ed.keep_selection = false;
|
let cursor = egui::text::CCursor::new(ed.caret);
|
||||||
output.state.cursor.set_char_range(Some(range));
|
output.state.cursor.set_char_range(Some(egui::text::CCursorRange::one(cursor)));
|
||||||
output.state.store(ui.ctx(), id);
|
output.state.store(ui.ctx(), id);
|
||||||
} else if resp.clicked() || resp.gained_focus() {
|
} else if resp.clicked() || resp.gained_focus() {
|
||||||
// Clicking into the other editor moves editing there.
|
// Clicking into the other editor moves editing there.
|
||||||
@@ -490,12 +348,8 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
|||||||
ed.enter_mode = false;
|
ed.enter_mode = false;
|
||||||
app.session.mode = gridcraft_engine::Mode::Edit;
|
app.session.mode = gridcraft_engine::Mode::Edit;
|
||||||
}
|
}
|
||||||
if !synced
|
if !synced && let Some(r) = output.cursor_range {
|
||||||
&& !dialog_open
|
|
||||||
&& let Some(r) = output.cursor_range
|
|
||||||
{
|
|
||||||
ed.caret = r.primary.index.into();
|
ed.caret = r.primary.index.into();
|
||||||
ed.anchor = r.secondary.index.into();
|
|
||||||
}
|
}
|
||||||
if resp.changed() {
|
if resp.changed() {
|
||||||
ed.completion = None;
|
ed.completion = None;
|
||||||
@@ -534,11 +388,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
|||||||
let g = ui.painter().layout_no_wrap(
|
let g = ui.painter().layout_no_wrap(
|
||||||
rest.to_string(),
|
rest.to_string(),
|
||||||
output.galley.job.sections.first().map(|s| s.format.font_id.clone()).unwrap_or_default(),
|
output.galley.job.sections.first().map(|s| s.format.font_id.clone()).unwrap_or_default(),
|
||||||
t.on_accent,
|
Color32::WHITE,
|
||||||
);
|
);
|
||||||
let r = egui::Rect::from_min_size(pos, g.size());
|
let r = egui::Rect::from_min_size(pos, g.size());
|
||||||
ui.painter().rect_filled(r, 0.0, t.accent);
|
ui.painter().rect_filled(r, 0.0, Color32::from_rgb(0x2E, 0x6F, 0xD8));
|
||||||
ui.painter().galley(pos, g, t.on_accent);
|
ui.painter().galley(pos, g, Color32::WHITE);
|
||||||
}
|
}
|
||||||
// Autocomplete list and argument hint under the editor.
|
// Autocomplete list and argument hint under the editor.
|
||||||
if mine && ui.is_rect_visible(resp.rect) && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) {
|
if mine && ui.is_rect_visible(resp.rect) && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) {
|
||||||
@@ -602,8 +456,6 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let lines = output.galley.rows.len().max(1);
|
|
||||||
let caret = Some(output.galley.pos_from_cursor(egui::text::CCursor::new(ed.caret)).translate(output.galley_pos.to_vec2()));
|
|
||||||
app.editor = Some(ed);
|
app.editor = Some(ed);
|
||||||
if cancel {
|
if cancel {
|
||||||
app.cancel_edit();
|
app.cancel_edit();
|
||||||
@@ -614,7 +466,6 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
|||||||
app.commit_edit(dr, dc, array, fill);
|
app.commit_edit(dr, dc, array, fill);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EditorInfo { lines, caret }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cycle_anchor(ed: &mut crate::editor::EditState) {
|
fn cycle_anchor(ed: &mut crate::editor::EditState) {
|
||||||
@@ -835,21 +686,4 @@ mod tests {
|
|||||||
assert!(entry_shrank(4, "Som"));
|
assert!(entry_shrank(4, "Som"));
|
||||||
assert!(!entry_shrank(4, "Same"));
|
assert!(!entry_shrank(4, "Same"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn one_line_fits_a_row_of_any_font() {
|
|
||||||
// macOS and Linux UI fonts: rows of 15 to 16 px keep Excel's 30 px bar.
|
|
||||||
assert_eq!(one_line(15.6), 30.0);
|
|
||||||
assert_eq!(one_line(16.0), 30.0);
|
|
||||||
// The Windows UI font's 17.28 px rows need more: the panel's and the field's margins take
|
|
||||||
// 12 px, and rounding to the pixel grid up to another one, so 30 px held only four rows
|
|
||||||
// of five (a tester's finding). Every counted line must fit in what is left.
|
|
||||||
for row_h in [17.0_f32, 17.28, 18.0, 20.0] {
|
|
||||||
for lines in 1..=MAX_LINES {
|
|
||||||
let bar = one_line(row_h) + (lines - 1) as f32 * row_h;
|
|
||||||
let inner = bar - 13.0;
|
|
||||||
assert!((inner / row_h).floor() as usize >= lines, "{lines} lines of {row_h} px in a {bar} px bar");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-85
@@ -12,7 +12,7 @@ use gridcraft_engine::model::{BorderLine, BorderStyle, Color, HAlign, LineIndex,
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::SheetApp;
|
use crate::SheetApp;
|
||||||
use crate::editor::EditState;
|
use crate::editor::{EditState, REF_COLORS};
|
||||||
use crate::theme::{self, Tokens};
|
use crate::theme::{self, Tokens};
|
||||||
|
|
||||||
pub const COL_HEADER_H: f32 = 21.0;
|
pub const COL_HEADER_H: f32 = 21.0;
|
||||||
@@ -163,11 +163,6 @@ pub struct GridState {
|
|||||||
/// The PivotTable Fields pane's `pivot.fields` answer, cached per (doc uid, revision, pivot):
|
/// The PivotTable Fields pane's `pivot.fields` answer, cached per (doc uid, revision, pivot):
|
||||||
/// it scans the whole source list, far too slow to repeat every frame (#2113).
|
/// it scans the whole source list, far too slow to repeat every frame (#2113).
|
||||||
pub pivot_fields: Option<((u64, u64, String), serde_json::Value)>,
|
pub pivot_fields: Option<((u64, u64, String), serde_json::Value)>,
|
||||||
/// The cells of a PivotTable report where the cell menu offers Show Details, relative to the
|
|
||||||
/// report's top-left and cached per (doc uid, revision, sheet, pivot): finding them computes
|
|
||||||
/// the whole report. `None` when the report can't be computed or is out of date: every cell
|
|
||||||
/// of it offers Show Details, and running it says why.
|
|
||||||
pub show_details_cells: Option<((u64, u64, usize, usize), Option<std::collections::HashSet<(u32, u32)>>)>,
|
|
||||||
pub header_menu: Option<(Pos2, bool)>,
|
pub header_menu: Option<(Pos2, bool)>,
|
||||||
/// Page Layout view pagination, cached per (doc uid, revision, sheet, rows, columns).
|
/// Page Layout view pagination, cached per (doc uid, revision, sheet, rows, columns).
|
||||||
pub paper: Option<crate::paper_view::PaperCache>,
|
pub paper: Option<crate::paper_view::PaperCache>,
|
||||||
@@ -181,11 +176,6 @@ pub struct GridState {
|
|||||||
pub geo: Option<Geo>,
|
pub geo: Option<Geo>,
|
||||||
/// The horizontal scrollbar's track in the sheet tab bar (hit-testing for tests).
|
/// The horizontal scrollbar's track in the sheet tab bar (hit-testing for tests).
|
||||||
pub hscroll: Option<Rect>,
|
pub hscroll: Option<Rect>,
|
||||||
/// The handle between the sheet tabs and the horizontal scrollbar (hit-testing for tests).
|
|
||||||
pub tab_split: Option<Rect>,
|
|
||||||
/// The share of the tab bar each workbook's tabs (and New sheet) take after the handle was
|
|
||||||
/// dragged, by document uid. A workbook without one gives its tabs up to 60%.
|
|
||||||
pub tab_shares: std::collections::HashMap<u64, f32>,
|
|
||||||
/// Ctrl+wheel zoom in progress (see [`WheelZoom`]).
|
/// Ctrl+wheel zoom in progress (see [`WheelZoom`]).
|
||||||
pub wheel_zoom: Option<WheelZoom>,
|
pub wheel_zoom: Option<WheelZoom>,
|
||||||
/// A one-finger touch drag in progress: it pans the sheet instead of selecting.
|
/// A one-finger touch drag in progress: it pans the sheet instead of selecting.
|
||||||
@@ -262,8 +252,8 @@ impl Geo {
|
|||||||
let (fr, fc) = sh.freeze.unwrap_or((0, 0));
|
let (fr, fc) = sh.freeze.unwrap_or((0, 0));
|
||||||
let frozen_w = (ci.start(fc) as f32) * z;
|
let frozen_w = (ci.start(fc) as f32) * z;
|
||||||
let frozen_h = (ri.start(fr) as f32) * z;
|
let frozen_h = (ri.start(fr) as f32) * z;
|
||||||
let row_outline = Arc::new(crate::outline::Axis::of(sh, true));
|
let row_outline = Arc::new(crate::outline::Axis::new(&sh.rows));
|
||||||
let col_outline = Arc::new(crate::outline::Axis::of(sh, false));
|
let col_outline = Arc::new(crate::outline::Axis::new(&sh.cols));
|
||||||
// Show Outline Symbols off: no bands (the outline's hidden lines stay hidden).
|
// Show Outline Symbols off: no bands (the outline's hidden lines stay hidden).
|
||||||
let symbols = sh.show_outline_symbols;
|
let symbols = sh.show_outline_symbols;
|
||||||
let mut band_w = if symbols { crate::outline::band(row_outline.levels, zoom) } else { 0.0 };
|
let mut band_w = if symbols { crate::outline::band(row_outline.levels, zoom) } else { 0.0 };
|
||||||
@@ -1237,7 +1227,7 @@ pub(crate) fn paint_quadrant(
|
|||||||
StrokeKind::Inside,
|
StrokeKind::Inside,
|
||||||
);
|
);
|
||||||
if on {
|
if on {
|
||||||
crate::icons::paint_on_sheet(p, b.shrink(1.5), crate::icons::Icon::Check, Color32::WHITE);
|
crate::icons::paint(p, b.shrink(1.5), crate::icons::Icon::Check, Color32::WHITE);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1568,15 +1558,9 @@ pub fn table_headings(geo: &Geo, sh: &Sheet, active: CellRef) -> Vec<TableHeadin
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn paint_filter_button(p: &Painter, r: Rect, filtered: bool) {
|
fn paint_filter_button(p: &Painter, r: Rect, filtered: bool) {
|
||||||
let t = Tokens::get(p.ctx());
|
p.rect_filled(r, 2.0, Color32::from_gray(250));
|
||||||
p.rect_filled(r, 2.0, t.sheet_control_bg);
|
p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_gray(170)), StrokeKind::Inside);
|
||||||
p.rect_stroke(r, 2.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Inside);
|
crate::icons::paint(p, r.shrink(1.5), if filtered { crate::icons::Icon::Filter } else { crate::icons::Icon::Chevron }, Color32::from_gray(70));
|
||||||
crate::icons::paint_on_sheet(
|
|
||||||
p,
|
|
||||||
r.shrink(1.5),
|
|
||||||
if filtered { crate::icons::Icon::Filter } else { crate::icons::Icon::Chevron },
|
|
||||||
t.sheet_control_text,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the
|
/// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the
|
||||||
@@ -1805,7 +1789,7 @@ pub(crate) fn paint_selection(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let rr = geo.range_rect(sh, r);
|
let rr = geo.range_rect(sh, r);
|
||||||
let col = Tokens::get(p.ctx()).ref_color(idx, true);
|
let col = REF_COLORS[idx];
|
||||||
p.rect_filled(rr, 0.0, col.gamma_multiply(0.12));
|
p.rect_filled(rr, 0.0, col.gamma_multiply(0.12));
|
||||||
p.rect_stroke(rr, 0.0, Stroke::new(1.5, col), StrokeKind::Inside);
|
p.rect_stroke(rr, 0.0, Stroke::new(1.5, col), StrokeKind::Inside);
|
||||||
for corner in [rr.left_top(), rr.right_top(), rr.left_bottom(), rr.right_bottom()] {
|
for corner in [rr.left_top(), rr.right_top(), rr.left_bottom(), rr.right_bottom()] {
|
||||||
@@ -2200,8 +2184,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
} else {
|
||||||
|
app.selected_chart = None;
|
||||||
}
|
}
|
||||||
app.selected_chart = None;
|
|
||||||
}
|
}
|
||||||
// A right-click on a chart, picture, shape or slicer opens the object's menu, not the cell's
|
// A right-click on a chart, picture, shape or slicer opens the object's menu, not the cell's
|
||||||
// under it; a right press that wobbles into a drag counts too (#2067).
|
// under it; a right press that wobbles into a drag counts too (#2067).
|
||||||
@@ -3164,9 +3149,6 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
|
|||||||
let size = st.font.size * 96.0 / 72.0 * geo.z;
|
let size = st.font.size * 96.0 / 72.0 * geo.z;
|
||||||
let font = FontId::new(size, theme::cell_family(&st.font.name, st.font.bold, st.font.italic));
|
let font = FontId::new(size, theme::cell_family(&st.font.name, st.font.bold, st.font.italic));
|
||||||
let lines = ed.text.lines().count().max(1) as f32 + if ed.text.ends_with('\n') { 1.0 } else { 0.0 };
|
let lines = ed.text.lines().count().max(1) as f32 + if ed.text.ends_with('\n') { 1.0 } else { 0.0 };
|
||||||
// Grows with the text: the in-cell editor shows every line and never scrolls (the formula bar
|
|
||||||
// is the one that stops at five lines).
|
|
||||||
let shown = lines;
|
|
||||||
// Wrap Text: the editor keeps the column's width and wraps, growing down as Excel's does.
|
// Wrap Text: the editor keeps the column's width and wraps, growing down as Excel's does.
|
||||||
// Otherwise it widens to the text's one-line width.
|
// Otherwise it widens to the text's one-line width.
|
||||||
let wrap = st.align.wrap;
|
let wrap = st.align.wrap;
|
||||||
@@ -3174,7 +3156,7 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
|
|||||||
// The field wraps at the width it is given (the editor's less its 2 px inset a side): its
|
// The field wraps at the width it is given (the editor's less its 2 px inset a side): its
|
||||||
// frame is `Frame::NONE`, so no text margin is taken off.
|
// frame is `Frame::NONE`, so no text margin is taken off.
|
||||||
let wrapped = ui.painter().layout(ed.text.clone(), font.clone(), Color32::BLACK, (rect.width() - 4.0).max(1.0));
|
let wrapped = ui.painter().layout(ed.text.clone(), font.clone(), Color32::BLACK, (rect.width() - 4.0).max(1.0));
|
||||||
(rect.width(), (wrapped.size().y.max(size * 1.3 * shown) + 4.0).max(rect.height()))
|
(rect.width(), (wrapped.size().y.max(size * 1.3 * lines) + 4.0).max(rect.height()))
|
||||||
} else {
|
} else {
|
||||||
let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x;
|
let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x;
|
||||||
// Excel keeps the cell's height for one line and only grows for extra lines.
|
// Excel keeps the cell's height for one line and only grows for extra lines.
|
||||||
@@ -3202,8 +3184,7 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
|
|||||||
// The theme's caret is meant for the UI chrome and vanishes on some fills (and, in the dark
|
// The theme's caret is meant for the UI chrome and vanishes on some fills (and, in the dark
|
||||||
// theme, on the white paper): pick black or white against the fill, like Excel.
|
// theme, on the white paper): pick black or white against the fill, like Excel.
|
||||||
child.visuals_mut().text_cursor.stroke.color = ink_on(fill);
|
child.visuals_mut().text_cursor.stroke.color = ink_on(fill);
|
||||||
crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0, erect.height() - 2.0);
|
crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0);
|
||||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("gridcraft.cell_editor.rect"), erect));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test the popup itself, not whether the pointer is over any part of the app.
|
/// Test the popup itself, not whether the pointer is over any part of the app.
|
||||||
@@ -3372,12 +3353,6 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
|
|||||||
if let Some(item) = insert_copied {
|
if let Some(item) = insert_copied {
|
||||||
items.insert(6, item);
|
items.insert(6, item);
|
||||||
}
|
}
|
||||||
if show_details_offered(app)
|
|
||||||
&& let Some(i) = items.iter().position(|(_, id)| *id == "data.sortDescending")
|
|
||||||
{
|
|
||||||
items.insert(i + 1, ("-", ""));
|
|
||||||
items.insert(i + 2, ("Show Details", "pivot.showDetails"));
|
|
||||||
}
|
|
||||||
let lang = crate::i18n::current(ui.ctx());
|
let lang = crate::i18n::current(ui.ctx());
|
||||||
for (label, id) in items {
|
for (label, id) in items {
|
||||||
if label == "-" {
|
if label == "-" {
|
||||||
@@ -3434,48 +3409,10 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the cell menu offers Show Details (Excel's right-click › Show Details): the active cell
|
|
||||||
/// is a value, subtotal or grand total cell of a PivotTable, or a cell of a report that is out of
|
|
||||||
/// date, where running the command says so. Labels and headers of a current report don't offer it,
|
|
||||||
/// and no cell does while the PivotTable's Enable show details option is off.
|
|
||||||
fn show_details_offered(app: &mut SheetApp) -> bool {
|
|
||||||
let Some(d) = app.session.active() else { return false };
|
|
||||||
let (si, at) = (d.wb.active_sheet, d.selection.active);
|
|
||||||
let Some((pi, pt)) = d.wb.active().and_then(|sh| sh.pivots.iter().enumerate().find(|(_, pt)| pt.last_range.is_some_and(|r| r.contains(at))))
|
|
||||||
else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
if !pt.enable_show_details {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let origin = pt.last_range.map(|r| r.start).unwrap_or_default();
|
|
||||||
let cell = (at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col));
|
|
||||||
let key = (d.uid, d.revision, si, pi);
|
|
||||||
if let Some((k, cells)) = &app.grid.show_details_cells
|
|
||||||
&& *k == key
|
|
||||||
{
|
|
||||||
return cells.as_ref().is_none_or(|c| c.contains(&cell));
|
|
||||||
}
|
|
||||||
let cells = gridcraft_engine::pivot::detail_cells(&d.wb, pt, si).ok();
|
|
||||||
let on = cells.as_ref().is_none_or(|c| c.contains(&cell));
|
|
||||||
app.grid.show_details_cells = Some((key, cells));
|
|
||||||
on
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The ways to fill a dragged range, as the fill menu and the Auto Fill Options button list them.
|
/// The ways to fill a dragged range, as the fill menu and the Auto Fill Options button list them.
|
||||||
const FILL_MODES: [(&str, &str); 4] =
|
const FILL_MODES: [(&str, &str); 4] =
|
||||||
[("Copy Cells", "copy"), ("Fill Series", "extend"), ("Fill Formatting Only", "formats"), ("Fill Without Formatting", "values")];
|
[("Copy Cells", "copy"), ("Fill Series", "extend"), ("Fill Formatting Only", "formats"), ("Fill Without Formatting", "values")];
|
||||||
|
|
||||||
/// The ways to fill from a date, which both menus add after [`FILL_MODES`] when the source starts
|
|
||||||
/// with one.
|
|
||||||
const DATE_FILL_MODES: [(&str, &str); 4] = [("Fill Days", "days"), ("Fill Weekdays", "weekdays"), ("Fill Months", "months"), ("Fill Years", "years")];
|
|
||||||
|
|
||||||
/// The fill modes the menus list for a fill from `src` on the active sheet.
|
|
||||||
fn fill_modes(app: &SheetApp, src: RangeRef) -> Vec<(&'static str, &'static str)> {
|
|
||||||
let dates = app.session.active().is_some_and(|d| gridcraft_engine::fill::starts_with_date(&d.wb, d.wb.active_sheet, src));
|
|
||||||
FILL_MODES.into_iter().chain(DATE_FILL_MODES.into_iter().filter(|_| dates)).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remembers the fill just made so the Auto Fill Options button can offer the other ways.
|
/// Remembers the fill just made so the Auto Fill Options button can offer the other ways.
|
||||||
fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) {
|
fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) {
|
||||||
app.grid.fill_tag =
|
app.grid.fill_tag =
|
||||||
@@ -3484,8 +3421,7 @@ fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) {
|
|||||||
|
|
||||||
/// The Auto Fill Options button below the bottom-right corner of cells a left-drag of the fill
|
/// The Auto Fill Options button below the bottom-right corner of cells a left-drag of the fill
|
||||||
/// handle just filled. Its menu redoes that fill as Copy Cells, Fill Series, Fill Formatting Only
|
/// handle just filled. Its menu redoes that fill as Copy Cells, Fill Series, Fill Formatting Only
|
||||||
/// or Fill Without Formatting, and from a date also as Fill Days, Fill Weekdays, Fill Months or
|
/// or Fill Without Formatting: it undoes the drag's fill, then runs `edit.autoFill` in that mode.
|
||||||
/// Fill Years: it undoes the drag's fill, then runs `edit.autoFill` in that mode.
|
|
||||||
/// The button goes away as soon as anything else changes the workbook or the selection moves.
|
/// The button goes away as soon as anything else changes the workbook or the selection moves.
|
||||||
fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) {
|
fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) {
|
||||||
let Some(tag) = app.grid.fill_tag.clone() else { return };
|
let Some(tag) = app.grid.fill_tag.clone() else { return };
|
||||||
@@ -3528,10 +3464,9 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet
|
|||||||
Stroke::NONE,
|
Stroke::NONE,
|
||||||
));
|
));
|
||||||
let mut chosen = None;
|
let mut chosen = None;
|
||||||
let modes = fill_modes(app, tag.src);
|
|
||||||
egui::Popup::menu(&resp).show(|ui| {
|
egui::Popup::menu(&resp).show(|ui| {
|
||||||
ui.set_min_width(190.0);
|
ui.set_min_width(190.0);
|
||||||
for (label, mode) in modes {
|
for (label, mode) in FILL_MODES {
|
||||||
if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() {
|
if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() {
|
||||||
chosen = Some(mode);
|
chosen = Some(mode);
|
||||||
ui.close();
|
ui.close();
|
||||||
@@ -3558,11 +3493,10 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet
|
|||||||
fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
|
fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
|
||||||
let Some((src, target, at)) = app.grid.fill_menu else { return };
|
let Some((src, target, at)) = app.grid.fill_menu else { return };
|
||||||
let mut close = false;
|
let mut close = false;
|
||||||
let mut items = fill_modes(app, src);
|
|
||||||
items.extend([("-", ""), ("Series…", "ui:series")]);
|
|
||||||
let response = egui::Area::new(egui::Id::new("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
let response = egui::Area::new(egui::Id::new("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||||
ui.set_min_width(190.0);
|
ui.set_min_width(190.0);
|
||||||
|
let items = [FILL_MODES[0], FILL_MODES[1], FILL_MODES[2], FILL_MODES[3], ("-", ""), ("Series…", "ui:series")];
|
||||||
let lang = crate::i18n::current(ui.ctx());
|
let lang = crate::i18n::current(ui.ctx());
|
||||||
for (label, mode) in items {
|
for (label, mode) in items {
|
||||||
if label == "-" {
|
if label == "-" {
|
||||||
@@ -3728,10 +3662,9 @@ pub(crate) fn paint_overlays(
|
|||||||
if app.editor.is_none()
|
if app.editor.is_none()
|
||||||
&& let Some(r) = validation_arrow(sh, geo, sel.active)
|
&& let Some(r) = validation_arrow(sh, geo, sel.active)
|
||||||
{
|
{
|
||||||
let t = Tokens::get(p.ctx());
|
p.rect_filled(r, 2.0, Color32::from_gray(245));
|
||||||
p.rect_filled(r, 2.0, t.sheet_control_bg);
|
p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_gray(160)), StrokeKind::Inside);
|
||||||
p.rect_stroke(r, 2.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Inside);
|
crate::icons::paint(p, r.shrink(3.0), crate::icons::Icon::Chevron, Color32::from_gray(60));
|
||||||
crate::icons::paint_on_sheet(p, r.shrink(3.0), crate::icons::Icon::Chevron, t.sheet_control_text);
|
|
||||||
}
|
}
|
||||||
// Input message of the active cell's validation, hidden while its list is open: it would cover the items.
|
// Input message of the active cell's validation, hidden while its list is open: it would cover the items.
|
||||||
if app.editor.is_none()
|
if app.editor.is_none()
|
||||||
|
|||||||
@@ -142,7 +142,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Remove Arrows" => "Odebrat šipky",
|
"Remove Arrows" => "Odebrat šipky",
|
||||||
"Show Formulas" => "Zobrazit vzorce",
|
"Show Formulas" => "Zobrazit vzorce",
|
||||||
"Error Checking" => "Kontrola chyb",
|
"Error Checking" => "Kontrola chyb",
|
||||||
"Circular References" => "Cyklické odkazy",
|
|
||||||
"Evaluate Formula" => "Vyhodnotit vzorec",
|
"Evaluate Formula" => "Vyhodnotit vzorec",
|
||||||
"Watch Window" => "Okno sledování",
|
"Watch Window" => "Okno sledování",
|
||||||
"Calculation\nOptions" => "Možnosti\nvýpočtu",
|
"Calculation\nOptions" => "Možnosti\nvýpočtu",
|
||||||
@@ -207,8 +206,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Footer" => "Zápatí",
|
"Footer" => "Zápatí",
|
||||||
"Different First Page" => "Jiná první stránka",
|
"Different First Page" => "Jiná první stránka",
|
||||||
"Different Odd & Even Pages" => "Jiné liché a sudé stránky",
|
"Different Odd & Even Pages" => "Jiné liché a sudé stránky",
|
||||||
"Scale with Document" => "Měnit velikost s dokumentem",
|
|
||||||
"Align with Page Margins" => "Zarovnat k okrajům stránky",
|
|
||||||
"Go to Footer" => "Přejít na zápatí",
|
"Go to Footer" => "Přejít na zápatí",
|
||||||
"Go to Header" => "Přejít na záhlaví",
|
"Go to Header" => "Přejít na záhlaví",
|
||||||
"Go to\nFooter" => "Přejít\nna zápatí",
|
"Go to\nFooter" => "Přejít\nna zápatí",
|
||||||
|
|||||||
@@ -146,7 +146,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Remove Arrows" => "Pfeile entfernen",
|
"Remove Arrows" => "Pfeile entfernen",
|
||||||
"Show Formulas" => "Formeln anzeigen",
|
"Show Formulas" => "Formeln anzeigen",
|
||||||
"Error Checking" => "Fehlerüberprüfung",
|
"Error Checking" => "Fehlerüberprüfung",
|
||||||
"Circular References" => "Zirkelbezüge",
|
|
||||||
"Evaluate Formula" => "Formel auswerten",
|
"Evaluate Formula" => "Formel auswerten",
|
||||||
"Watch Window" => "Überwachungsfenster",
|
"Watch Window" => "Überwachungsfenster",
|
||||||
"Calculation\nOptions" => "Berechnungs-\noptionen",
|
"Calculation\nOptions" => "Berechnungs-\noptionen",
|
||||||
@@ -211,8 +210,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Footer" => "Fußzeile",
|
"Footer" => "Fußzeile",
|
||||||
"Different First Page" => "Erste Seite anders",
|
"Different First Page" => "Erste Seite anders",
|
||||||
"Different Odd & Even Pages" => "Gerade und ungerade Seiten anders",
|
"Different Odd & Even Pages" => "Gerade und ungerade Seiten anders",
|
||||||
"Scale with Document" => "Mit Dokument skalieren",
|
|
||||||
"Align with Page Margins" => "An Seitenrändern ausrichten",
|
|
||||||
"Go to Footer" => "Zur Fußzeile wechseln",
|
"Go to Footer" => "Zur Fußzeile wechseln",
|
||||||
"Go to Header" => "Zur Kopfzeile wechseln",
|
"Go to Header" => "Zur Kopfzeile wechseln",
|
||||||
"Go to\nFooter" => "Zur\nFußzeile",
|
"Go to\nFooter" => "Zur\nFußzeile",
|
||||||
|
|||||||
@@ -141,7 +141,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Remove Arrows" => "Κατάργηση βελών",
|
"Remove Arrows" => "Κατάργηση βελών",
|
||||||
"Show Formulas" => "Εμφάνιση τύπων",
|
"Show Formulas" => "Εμφάνιση τύπων",
|
||||||
"Error Checking" => "Έλεγχος σφαλμάτων",
|
"Error Checking" => "Έλεγχος σφαλμάτων",
|
||||||
"Circular References" => "Κυκλικές αναφορές",
|
|
||||||
"Evaluate Formula" => "Αξιολόγηση τύπου",
|
"Evaluate Formula" => "Αξιολόγηση τύπου",
|
||||||
"Watch Window" => "Παράθυρο παρακολούθησης",
|
"Watch Window" => "Παράθυρο παρακολούθησης",
|
||||||
"Calculation\nOptions" => "Επιλογές\nυπολογισμού",
|
"Calculation\nOptions" => "Επιλογές\nυπολογισμού",
|
||||||
@@ -203,8 +202,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Footer" => "Υποσέλιδο",
|
"Footer" => "Υποσέλιδο",
|
||||||
"Different First Page" => "Διαφορετική πρώτη σελίδα",
|
"Different First Page" => "Διαφορετική πρώτη σελίδα",
|
||||||
"Different Odd & Even Pages" => "Διαφορετικές μονές και ζυγές σελίδες",
|
"Different Odd & Even Pages" => "Διαφορετικές μονές και ζυγές σελίδες",
|
||||||
"Scale with Document" => "Κλιμάκωση με το έγγραφο",
|
|
||||||
"Align with Page Margins" => "Στοίχιση με τα περιθώρια σελίδας",
|
|
||||||
"Go to Footer" => "Μετάβαση στο υποσέλιδο",
|
"Go to Footer" => "Μετάβαση στο υποσέλιδο",
|
||||||
"Go to Header" => "Μετάβαση στην κεφαλίδα",
|
"Go to Header" => "Μετάβαση στην κεφαλίδα",
|
||||||
"Go to\nFooter" => "Μετάβαση στο\nυποσέλιδο",
|
"Go to\nFooter" => "Μετάβαση στο\nυποσέλιδο",
|
||||||
@@ -880,7 +877,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Doughnut" => "Δακτύλιος",
|
"Doughnut" => "Δακτύλιος",
|
||||||
"Area" => "Περιοχή",
|
"Area" => "Περιοχή",
|
||||||
"Stacked Area" => "Σωρευμένη περιοχή",
|
"Stacked Area" => "Σωρευμένη περιοχή",
|
||||||
"100% Stacked Area" => "Σωρευμένη περιοχή 100%",
|
|
||||||
"Scatter" => "Διασπορά",
|
"Scatter" => "Διασπορά",
|
||||||
"Scatter with Lines" => "Διασπορά με γραμμές",
|
"Scatter with Lines" => "Διασπορά με γραμμές",
|
||||||
"Radar" => "Ακτινωτό",
|
"Radar" => "Ακτινωτό",
|
||||||
|
|||||||
@@ -145,7 +145,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Remove Arrows" => "Quitar flechas",
|
"Remove Arrows" => "Quitar flechas",
|
||||||
"Show Formulas" => "Mostrar fórmulas",
|
"Show Formulas" => "Mostrar fórmulas",
|
||||||
"Error Checking" => "Comprobación de errores",
|
"Error Checking" => "Comprobación de errores",
|
||||||
"Circular References" => "Referencias circulares",
|
|
||||||
"Evaluate Formula" => "Evaluar fórmula",
|
"Evaluate Formula" => "Evaluar fórmula",
|
||||||
"Watch Window" => "Ventana Inspección",
|
"Watch Window" => "Ventana Inspección",
|
||||||
"Calculation\nOptions" => "Opciones\nde cálculo",
|
"Calculation\nOptions" => "Opciones\nde cálculo",
|
||||||
@@ -210,8 +209,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Footer" => "Pie de página",
|
"Footer" => "Pie de página",
|
||||||
"Different First Page" => "Primera página diferente",
|
"Different First Page" => "Primera página diferente",
|
||||||
"Different Odd & Even Pages" => "Páginas pares e impares diferentes",
|
"Different Odd & Even Pages" => "Páginas pares e impares diferentes",
|
||||||
"Scale with Document" => "Escalar con el documento",
|
|
||||||
"Align with Page Margins" => "Alinear con los márgenes de página",
|
|
||||||
"Go to Footer" => "Ir al pie de página",
|
"Go to Footer" => "Ir al pie de página",
|
||||||
"Go to Header" => "Ir al encabezado",
|
"Go to Header" => "Ir al encabezado",
|
||||||
"Go to\nFooter" => "Ir al pie\nde página",
|
"Go to\nFooter" => "Ir al pie\nde página",
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
Some(match text {
|
Some(match text {
|
||||||
"Different First Page" => "Première page différente",
|
"Different First Page" => "Première page différente",
|
||||||
"Different Odd & Even Pages" => "Pages paires et impaires différentes",
|
"Different Odd & Even Pages" => "Pages paires et impaires différentes",
|
||||||
"Scale with Document" => "Mettre à l'échelle avec le document",
|
|
||||||
"Align with Page Margins" => "Aligner sur les marges de la page",
|
|
||||||
"Paste" => "Coller",
|
"Paste" => "Coller",
|
||||||
"Paste Values" => "Coller les valeurs",
|
"Paste Values" => "Coller les valeurs",
|
||||||
"Paste Formulas" => "Coller les formules",
|
"Paste Formulas" => "Coller les formules",
|
||||||
@@ -156,7 +154,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Remove Arrows" => "Supprimer les flèches",
|
"Remove Arrows" => "Supprimer les flèches",
|
||||||
"Show Formulas" => "Afficher les formules",
|
"Show Formulas" => "Afficher les formules",
|
||||||
"Error Checking" => "Vérification des erreurs",
|
"Error Checking" => "Vérification des erreurs",
|
||||||
"Circular References" => "Références circulaires",
|
|
||||||
"Evaluate Formula" => "Évaluation de formule",
|
"Evaluate Formula" => "Évaluation de formule",
|
||||||
"Watch Window" => "Fenêtre Espion",
|
"Watch Window" => "Fenêtre Espion",
|
||||||
"Calculation\nOptions" => "Options de\ncalcul",
|
"Calculation\nOptions" => "Options de\ncalcul",
|
||||||
|
|||||||
@@ -149,7 +149,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Remove Arrows" => "Rimuovi frecce",
|
"Remove Arrows" => "Rimuovi frecce",
|
||||||
"Show Formulas" => "Mostra formule",
|
"Show Formulas" => "Mostra formule",
|
||||||
"Error Checking" => "Controllo errori",
|
"Error Checking" => "Controllo errori",
|
||||||
"Circular References" => "Riferimenti circolari",
|
|
||||||
"Evaluate Formula" => "Valuta formula",
|
"Evaluate Formula" => "Valuta formula",
|
||||||
"Watch Window" => "Finestra controlli",
|
"Watch Window" => "Finestra controlli",
|
||||||
"Calculation\nOptions" => "Opzioni di\ncalcolo",
|
"Calculation\nOptions" => "Opzioni di\ncalcolo",
|
||||||
@@ -328,8 +327,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Go to Header" => "Vai a intestazione",
|
"Go to Header" => "Vai a intestazione",
|
||||||
"Different First Page" => "Prima pagina diversa",
|
"Different First Page" => "Prima pagina diversa",
|
||||||
"Different Odd & Even Pages" => "Pagine pari e dispari diverse",
|
"Different Odd & Even Pages" => "Pagine pari e dispari diverse",
|
||||||
"Scale with Document" => "Ridimensiona con il documento",
|
|
||||||
"Align with Page Margins" => "Allinea ai margini della pagina",
|
|
||||||
"Go to Footer" => "Vai a piè di pagina",
|
"Go to Footer" => "Vai a piè di pagina",
|
||||||
"Ruler" => "Righello",
|
"Ruler" => "Righello",
|
||||||
// File › Backstage view.
|
// File › Backstage view.
|
||||||
@@ -764,7 +761,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Doughnut" => "Anello",
|
"Doughnut" => "Anello",
|
||||||
"Area" => "Area",
|
"Area" => "Area",
|
||||||
"Stacked Area" => "Area in pila",
|
"Stacked Area" => "Area in pila",
|
||||||
"100% Stacked Area" => "Area in pila 100%",
|
|
||||||
"Scatter" => "Dispersione",
|
"Scatter" => "Dispersione",
|
||||||
"Scatter with Lines" => "Dispersione con linee",
|
"Scatter with Lines" => "Dispersione con linee",
|
||||||
"Radar" => "Radar",
|
"Radar" => "Radar",
|
||||||
|
|||||||
@@ -154,7 +154,6 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
|||||||
("Remove Arrows", "矢印の解除"),
|
("Remove Arrows", "矢印の解除"),
|
||||||
("Show Formulas", "数式の表示"),
|
("Show Formulas", "数式の表示"),
|
||||||
("Error Checking", "エラーチェック"),
|
("Error Checking", "エラーチェック"),
|
||||||
("Circular References", "循環参照"),
|
|
||||||
("Evaluate Formula", "数式の検証"),
|
("Evaluate Formula", "数式の検証"),
|
||||||
("Watch Window", "ウォッチ ウィンドウ"),
|
("Watch Window", "ウォッチ ウィンドウ"),
|
||||||
("Calculation\nOptions", "計算方法の設定"),
|
("Calculation\nOptions", "計算方法の設定"),
|
||||||
@@ -332,8 +331,6 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
|||||||
("Go to Header", "ヘッダーに移動"),
|
("Go to Header", "ヘッダーに移動"),
|
||||||
("Different First Page", "先頭ページのみ別指定"),
|
("Different First Page", "先頭ページのみ別指定"),
|
||||||
("Different Odd & Even Pages", "奇数/偶数ページ別指定"),
|
("Different Odd & Even Pages", "奇数/偶数ページ別指定"),
|
||||||
("Scale with Document", "文書に合わせて拡大/縮小"),
|
|
||||||
("Align with Page Margins", "ページ余白に合わせる"),
|
|
||||||
("Go to Footer", "フッターに移動"),
|
("Go to Footer", "フッターに移動"),
|
||||||
("Ruler", "ルーラー"),
|
("Ruler", "ルーラー"),
|
||||||
("File", "ファイル"),
|
("File", "ファイル"),
|
||||||
@@ -773,7 +770,6 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[
|
|||||||
("Doughnut", "ドーナツ"),
|
("Doughnut", "ドーナツ"),
|
||||||
("Area", "面"),
|
("Area", "面"),
|
||||||
("Stacked Area", "積み上げ面"),
|
("Stacked Area", "積み上げ面"),
|
||||||
("100% Stacked Area", "100% 積み上げ面"),
|
|
||||||
("Scatter", "散布図"),
|
("Scatter", "散布図"),
|
||||||
("Scatter with Lines", "直線付き散布図"),
|
("Scatter with Lines", "直線付き散布図"),
|
||||||
("Radar", "レーダー"),
|
("Radar", "レーダー"),
|
||||||
|
|||||||
@@ -151,7 +151,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Remove Arrows" => "화살표 제거",
|
"Remove Arrows" => "화살표 제거",
|
||||||
"Show Formulas" => "수식 표시",
|
"Show Formulas" => "수식 표시",
|
||||||
"Error Checking" => "오류 검사",
|
"Error Checking" => "오류 검사",
|
||||||
"Circular References" => "순환 참조",
|
|
||||||
"Evaluate Formula" => "수식 계산",
|
"Evaluate Formula" => "수식 계산",
|
||||||
"Watch Window" => "감시 창",
|
"Watch Window" => "감시 창",
|
||||||
"Calculation\nOptions" => "계산\n옵션",
|
"Calculation\nOptions" => "계산\n옵션",
|
||||||
@@ -333,8 +332,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Go to Header" => "머리글로 이동",
|
"Go to Header" => "머리글로 이동",
|
||||||
"Different First Page" => "첫 페이지를 다르게 지정",
|
"Different First Page" => "첫 페이지를 다르게 지정",
|
||||||
"Different Odd & Even Pages" => "짝수와 홀수 페이지를 다르게 지정",
|
"Different Odd & Even Pages" => "짝수와 홀수 페이지를 다르게 지정",
|
||||||
"Scale with Document" => "문서에 맞게 배율 조정",
|
|
||||||
"Align with Page Margins" => "페이지 여백에 맞추기",
|
|
||||||
"Go to Footer" => "바닥글로 이동",
|
"Go to Footer" => "바닥글로 이동",
|
||||||
"Ruler" => "눈금자",
|
"Ruler" => "눈금자",
|
||||||
// File › Backstage view.
|
// File › Backstage view.
|
||||||
|
|||||||
@@ -150,7 +150,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Remove Arrows" => "Pijlen verwijderen",
|
"Remove Arrows" => "Pijlen verwijderen",
|
||||||
"Show Formulas" => "Formules weergeven",
|
"Show Formulas" => "Formules weergeven",
|
||||||
"Error Checking" => "Foutopsporing",
|
"Error Checking" => "Foutopsporing",
|
||||||
"Circular References" => "Kringverwijzingen",
|
|
||||||
"Evaluate Formula" => "Formule evalueren",
|
"Evaluate Formula" => "Formule evalueren",
|
||||||
"Watch Window" => "Bewakingsvenster",
|
"Watch Window" => "Bewakingsvenster",
|
||||||
"Calculation\nOptions" => "Berekenings-\nopties",
|
"Calculation\nOptions" => "Berekenings-\nopties",
|
||||||
@@ -327,8 +326,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Go to Header" => "Naar koptekst",
|
"Go to Header" => "Naar koptekst",
|
||||||
"Different First Page" => "Eerste pagina afwijkend",
|
"Different First Page" => "Eerste pagina afwijkend",
|
||||||
"Different Odd & Even Pages" => "Even en oneven pagina's afwijkend",
|
"Different Odd & Even Pages" => "Even en oneven pagina's afwijkend",
|
||||||
"Scale with Document" => "Meeschalen met document",
|
|
||||||
"Align with Page Margins" => "Uitlijnen op paginamarges",
|
|
||||||
"Go to Footer" => "Naar voettekst",
|
"Go to Footer" => "Naar voettekst",
|
||||||
"Ruler" => "Liniaal",
|
"Ruler" => "Liniaal",
|
||||||
"File" => "Bestand",
|
"File" => "Bestand",
|
||||||
|
|||||||
@@ -145,7 +145,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Remove Arrows" => "Usuń strzałki",
|
"Remove Arrows" => "Usuń strzałki",
|
||||||
"Show Formulas" => "Pokaż formuły",
|
"Show Formulas" => "Pokaż formuły",
|
||||||
"Error Checking" => "Sprawdzanie błędów",
|
"Error Checking" => "Sprawdzanie błędów",
|
||||||
"Circular References" => "Odwołania cykliczne",
|
|
||||||
"Evaluate Formula" => "Szacuj formułę",
|
"Evaluate Formula" => "Szacuj formułę",
|
||||||
"Watch Window" => "Okno czujki",
|
"Watch Window" => "Okno czujki",
|
||||||
"Calculation\nOptions" => "Opcje\nobliczania",
|
"Calculation\nOptions" => "Opcje\nobliczania",
|
||||||
@@ -323,8 +322,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Go to Header" => "Przejdź do nagłówka",
|
"Go to Header" => "Przejdź do nagłówka",
|
||||||
"Different First Page" => "Inna pierwsza strona",
|
"Different First Page" => "Inna pierwsza strona",
|
||||||
"Different Odd & Even Pages" => "Inne strony nieparzyste i parzyste",
|
"Different Odd & Even Pages" => "Inne strony nieparzyste i parzyste",
|
||||||
"Scale with Document" => "Skaluj z dokumentem",
|
|
||||||
"Align with Page Margins" => "Wyrównaj do marginesów strony",
|
|
||||||
"Go to Footer" => "Przejdź do stopki",
|
"Go to Footer" => "Przejdź do stopki",
|
||||||
"Ruler" => "Linijka",
|
"Ruler" => "Linijka",
|
||||||
"File" => "Plik",
|
"File" => "Plik",
|
||||||
@@ -758,7 +755,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Doughnut" => "Pierścieniowy",
|
"Doughnut" => "Pierścieniowy",
|
||||||
"Area" => "Warstwowy",
|
"Area" => "Warstwowy",
|
||||||
"Stacked Area" => "Warstwowy skumulowany",
|
"Stacked Area" => "Warstwowy skumulowany",
|
||||||
"100% Stacked Area" => "Warstwowy 100% skumulowany",
|
|
||||||
"Scatter" => "Punktowy",
|
"Scatter" => "Punktowy",
|
||||||
"Scatter with Lines" => "Punktowy z liniami",
|
"Scatter with Lines" => "Punktowy z liniami",
|
||||||
"Radar" => "Radarowy",
|
"Radar" => "Radarowy",
|
||||||
|
|||||||
@@ -148,7 +148,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Remove Arrows" => "Remover Setas",
|
"Remove Arrows" => "Remover Setas",
|
||||||
"Show Formulas" => "Exibir Fórmulas",
|
"Show Formulas" => "Exibir Fórmulas",
|
||||||
"Error Checking" => "Verificação de Erros",
|
"Error Checking" => "Verificação de Erros",
|
||||||
"Circular References" => "Referências Circulares",
|
|
||||||
"Evaluate Formula" => "Avaliar Fórmula",
|
"Evaluate Formula" => "Avaliar Fórmula",
|
||||||
"Watch Window" => "Janela de Inspeção",
|
"Watch Window" => "Janela de Inspeção",
|
||||||
"Calculation\nOptions" => "Opções de\nCálculo",
|
"Calculation\nOptions" => "Opções de\nCálculo",
|
||||||
@@ -329,8 +328,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Go to Header" => "Ir para o Cabeçalho",
|
"Go to Header" => "Ir para o Cabeçalho",
|
||||||
"Different First Page" => "Primeira Página Diferente",
|
"Different First Page" => "Primeira Página Diferente",
|
||||||
"Different Odd & Even Pages" => "Páginas Pares e Ímpares Diferentes",
|
"Different Odd & Even Pages" => "Páginas Pares e Ímpares Diferentes",
|
||||||
"Scale with Document" => "Dimensionar com o Documento",
|
|
||||||
"Align with Page Margins" => "Alinhar com as Margens da Página",
|
|
||||||
"Go to Footer" => "Ir para o Rodapé",
|
"Go to Footer" => "Ir para o Rodapé",
|
||||||
"Ruler" => "Régua",
|
"Ruler" => "Régua",
|
||||||
// File › Backstage view.
|
// File › Backstage view.
|
||||||
@@ -775,7 +772,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Doughnut" => "Rosca",
|
"Doughnut" => "Rosca",
|
||||||
"Area" => "Área",
|
"Area" => "Área",
|
||||||
"Stacked Area" => "Área Empilhada",
|
"Stacked Area" => "Área Empilhada",
|
||||||
"100% Stacked Area" => "Área 100% Empilhada",
|
|
||||||
"Scatter" => "Dispersão",
|
"Scatter" => "Dispersão",
|
||||||
"Scatter with Lines" => "Dispersão com Linhas",
|
"Scatter with Lines" => "Dispersão com Linhas",
|
||||||
"Radar" => "Radar",
|
"Radar" => "Radar",
|
||||||
|
|||||||
@@ -152,7 +152,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Remove Arrows" => "Eliminare săgeți",
|
"Remove Arrows" => "Eliminare săgeți",
|
||||||
"Show Formulas" => "Afișare formule",
|
"Show Formulas" => "Afișare formule",
|
||||||
"Error Checking" => "Verificare erori",
|
"Error Checking" => "Verificare erori",
|
||||||
"Circular References" => "Referințe circulare",
|
|
||||||
"Evaluate Formula" => "Evaluare formulă",
|
"Evaluate Formula" => "Evaluare formulă",
|
||||||
"Watch Window" => "Fereastră de urmărire",
|
"Watch Window" => "Fereastră de urmărire",
|
||||||
"Calculation\nOptions" => "Opțiuni de\ncalcul",
|
"Calculation\nOptions" => "Opțiuni de\ncalcul",
|
||||||
@@ -330,8 +329,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Go to Header" => "Salt la antet",
|
"Go to Header" => "Salt la antet",
|
||||||
"Different First Page" => "Prima pagină diferită",
|
"Different First Page" => "Prima pagină diferită",
|
||||||
"Different Odd & Even Pages" => "Pagini pare și impare diferite",
|
"Different Odd & Even Pages" => "Pagini pare și impare diferite",
|
||||||
"Scale with Document" => "Scalare cu documentul",
|
|
||||||
"Align with Page Margins" => "Aliniere la marginile paginii",
|
|
||||||
"Go to Footer" => "Salt la subsol",
|
"Go to Footer" => "Salt la subsol",
|
||||||
"Ruler" => "Riglă",
|
"Ruler" => "Riglă",
|
||||||
"File" => "Fișier",
|
"File" => "Fișier",
|
||||||
@@ -764,7 +761,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Doughnut" => "Inel",
|
"Doughnut" => "Inel",
|
||||||
"Area" => "Arie",
|
"Area" => "Arie",
|
||||||
"Stacked Area" => "Arie stivuită",
|
"Stacked Area" => "Arie stivuită",
|
||||||
"100% Stacked Area" => "Arie 100% stivuită",
|
|
||||||
"Scatter" => "Dispersie",
|
"Scatter" => "Dispersie",
|
||||||
"Scatter with Lines" => "Dispersie cu linii",
|
"Scatter with Lines" => "Dispersie cu linii",
|
||||||
"Radar" => "Radar",
|
"Radar" => "Radar",
|
||||||
|
|||||||
@@ -151,7 +151,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Remove Arrows" => "Убрать стрелки",
|
"Remove Arrows" => "Убрать стрелки",
|
||||||
"Show Formulas" => "Показать формулы",
|
"Show Formulas" => "Показать формулы",
|
||||||
"Error Checking" => "Проверка ошибок",
|
"Error Checking" => "Проверка ошибок",
|
||||||
"Circular References" => "Циклические ссылки",
|
|
||||||
"Evaluate Formula" => "Вычислить формулу",
|
"Evaluate Formula" => "Вычислить формулу",
|
||||||
"Watch Window" => "Окно контрольных значений",
|
"Watch Window" => "Окно контрольных значений",
|
||||||
"Calculation\nOptions" => "Параметры\nвычислений",
|
"Calculation\nOptions" => "Параметры\nвычислений",
|
||||||
@@ -333,8 +332,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Go to Header" => "Перейти к верхнему колонтитулу",
|
"Go to Header" => "Перейти к верхнему колонтитулу",
|
||||||
"Different First Page" => "Особый колонтитул для первой страницы",
|
"Different First Page" => "Особый колонтитул для первой страницы",
|
||||||
"Different Odd & Even Pages" => "Разные колонтитулы для четных и нечетных страниц",
|
"Different Odd & Even Pages" => "Разные колонтитулы для четных и нечетных страниц",
|
||||||
"Scale with Document" => "Масштабировать вместе с документом",
|
|
||||||
"Align with Page Margins" => "Выровнять по полям страницы",
|
|
||||||
"Go to Footer" => "Перейти к нижнему колонтитулу",
|
"Go to Footer" => "Перейти к нижнему колонтитулу",
|
||||||
"Ruler" => "Линейка",
|
"Ruler" => "Линейка",
|
||||||
// File › Backstage view.
|
// File › Backstage view.
|
||||||
|
|||||||
@@ -146,7 +146,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Remove Arrows" => "Ukloni strelice",
|
"Remove Arrows" => "Ukloni strelice",
|
||||||
"Show Formulas" => "Prikaži formule",
|
"Show Formulas" => "Prikaži formule",
|
||||||
"Error Checking" => "Provera grešaka",
|
"Error Checking" => "Provera grešaka",
|
||||||
"Circular References" => "Kružne reference",
|
|
||||||
"Evaluate Formula" => "Proceni formulu",
|
"Evaluate Formula" => "Proceni formulu",
|
||||||
"Watch Window" => "Prozor za praćenje",
|
"Watch Window" => "Prozor za praćenje",
|
||||||
"Calculation\nOptions" => "Opcije\nizračunavanja",
|
"Calculation\nOptions" => "Opcije\nizračunavanja",
|
||||||
@@ -326,8 +325,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Go to Header" => "Idi na zaglavlje",
|
"Go to Header" => "Idi na zaglavlje",
|
||||||
"Different First Page" => "Drugačija prva stranica",
|
"Different First Page" => "Drugačija prva stranica",
|
||||||
"Different Odd & Even Pages" => "Drugačije neparne i parne stranice",
|
"Different Odd & Even Pages" => "Drugačije neparne i parne stranice",
|
||||||
"Scale with Document" => "Skaliraj sa dokumentom",
|
|
||||||
"Align with Page Margins" => "Poravnaj sa marginama stranice",
|
|
||||||
"Go to Footer" => "Idi na podnožje",
|
"Go to Footer" => "Idi na podnožje",
|
||||||
"Ruler" => "Lenjir",
|
"Ruler" => "Lenjir",
|
||||||
"File" => "Datoteka",
|
"File" => "Datoteka",
|
||||||
|
|||||||
@@ -144,7 +144,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Remove Arrows" => "Okları Kaldır",
|
"Remove Arrows" => "Okları Kaldır",
|
||||||
"Show Formulas" => "Formülleri Göster",
|
"Show Formulas" => "Formülleri Göster",
|
||||||
"Error Checking" => "Hata Denetimi",
|
"Error Checking" => "Hata Denetimi",
|
||||||
"Circular References" => "Döngüsel Başvurular",
|
|
||||||
"Evaluate Formula" => "Formülü Değerlendir",
|
"Evaluate Formula" => "Formülü Değerlendir",
|
||||||
"Watch Window" => "Gözcü Penceresi",
|
"Watch Window" => "Gözcü Penceresi",
|
||||||
"Calculation\nOptions" => "Hesaplama\nSeçenekleri",
|
"Calculation\nOptions" => "Hesaplama\nSeçenekleri",
|
||||||
@@ -206,8 +205,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Footer" => "Altbilgi",
|
"Footer" => "Altbilgi",
|
||||||
"Different First Page" => "İlk Sayfada Farklı",
|
"Different First Page" => "İlk Sayfada Farklı",
|
||||||
"Different Odd & Even Pages" => "Tek ve Çift Sayfalarda Farklı",
|
"Different Odd & Even Pages" => "Tek ve Çift Sayfalarda Farklı",
|
||||||
"Scale with Document" => "Belgeyle Ölçeklendir",
|
|
||||||
"Align with Page Margins" => "Sayfa Kenar Boşluklarıyla Hizala",
|
|
||||||
"Go to Footer" => "Altbilgiye Git",
|
"Go to Footer" => "Altbilgiye Git",
|
||||||
"Go to Header" => "Üstbilgiye Git",
|
"Go to Header" => "Üstbilgiye Git",
|
||||||
"Go to\nFooter" => "Altbilgiye\nGit",
|
"Go to\nFooter" => "Altbilgiye\nGit",
|
||||||
|
|||||||
@@ -147,7 +147,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Remove Arrows" => "Прибрати стрілки",
|
"Remove Arrows" => "Прибрати стрілки",
|
||||||
"Show Formulas" => "Показати формули",
|
"Show Formulas" => "Показати формули",
|
||||||
"Error Checking" => "Перевірка помилок",
|
"Error Checking" => "Перевірка помилок",
|
||||||
"Circular References" => "Циклічні посилання",
|
|
||||||
"Evaluate Formula" => "Обчислити формулу",
|
"Evaluate Formula" => "Обчислити формулу",
|
||||||
"Watch Window" => "Окно контрольних значень",
|
"Watch Window" => "Окно контрольних значень",
|
||||||
"Calculation\nOptions" => "Параметри\nобчислень",
|
"Calculation\nOptions" => "Параметри\nобчислень",
|
||||||
@@ -326,8 +325,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Go to Header" => "Перейти до верхнього колонтитула",
|
"Go to Header" => "Перейти до верхнього колонтитула",
|
||||||
"Different First Page" => "Окремий колонтитул для першої сторінки",
|
"Different First Page" => "Окремий колонтитул для першої сторінки",
|
||||||
"Different Odd & Even Pages" => "Різні колонтитули для парних і непарних сторінок",
|
"Different Odd & Even Pages" => "Різні колонтитули для парних і непарних сторінок",
|
||||||
"Scale with Document" => "Масштабувати разом із документом",
|
|
||||||
"Align with Page Margins" => "Вирівняти за полями сторінки",
|
|
||||||
"Go to Footer" => "Перейти до нижнього колонтитула",
|
"Go to Footer" => "Перейти до нижнього колонтитула",
|
||||||
"Ruler" => "Лінійка",
|
"Ruler" => "Лінійка",
|
||||||
// File › Backstage view.
|
// File › Backstage view.
|
||||||
@@ -761,7 +758,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Doughnut" => "Doughnut",
|
"Doughnut" => "Doughnut",
|
||||||
"Area" => "Area",
|
"Area" => "Area",
|
||||||
"Stacked Area" => "Stacked Area",
|
"Stacked Area" => "Stacked Area",
|
||||||
"100% Stacked Area" => "100% Stacked Area",
|
|
||||||
"Scatter" => "Scatter",
|
"Scatter" => "Scatter",
|
||||||
"Scatter with Lines" => "Scatter with Lines",
|
"Scatter with Lines" => "Scatter with Lines",
|
||||||
"Radar" => "Radar",
|
"Radar" => "Radar",
|
||||||
|
|||||||
@@ -153,7 +153,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Remove Arrows" => "Bỏ mũi tên",
|
"Remove Arrows" => "Bỏ mũi tên",
|
||||||
"Show Formulas" => "Hiện công thức",
|
"Show Formulas" => "Hiện công thức",
|
||||||
"Error Checking" => "Kiểm tra lỗi",
|
"Error Checking" => "Kiểm tra lỗi",
|
||||||
"Circular References" => "Tham chiếu vòng",
|
|
||||||
"Evaluate Formula" => "Đánh giá công thức",
|
"Evaluate Formula" => "Đánh giá công thức",
|
||||||
"Watch Window" => "Cửa sổ theo dõi",
|
"Watch Window" => "Cửa sổ theo dõi",
|
||||||
"Calculation\nOptions" => "Tùy chọn\ntính toán",
|
"Calculation\nOptions" => "Tùy chọn\ntính toán",
|
||||||
@@ -331,8 +330,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Go to Header" => "Đi tới đầu trang",
|
"Go to Header" => "Đi tới đầu trang",
|
||||||
"Different First Page" => "Trang đầu khác",
|
"Different First Page" => "Trang đầu khác",
|
||||||
"Different Odd & Even Pages" => "Trang chẵn lẻ khác nhau",
|
"Different Odd & Even Pages" => "Trang chẵn lẻ khác nhau",
|
||||||
"Scale with Document" => "Co giãn theo tài liệu",
|
|
||||||
"Align with Page Margins" => "Căn theo lề trang",
|
|
||||||
"Go to Footer" => "Đi tới chân trang",
|
"Go to Footer" => "Đi tới chân trang",
|
||||||
"Ruler" => "Thước",
|
"Ruler" => "Thước",
|
||||||
// File › Backstage view.
|
// File › Backstage view.
|
||||||
|
|||||||
@@ -151,7 +151,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Remove Arrows" => "删除箭头",
|
"Remove Arrows" => "删除箭头",
|
||||||
"Show Formulas" => "显示公式",
|
"Show Formulas" => "显示公式",
|
||||||
"Error Checking" => "错误检查",
|
"Error Checking" => "错误检查",
|
||||||
"Circular References" => "循环引用",
|
|
||||||
"Evaluate Formula" => "公式求值",
|
"Evaluate Formula" => "公式求值",
|
||||||
"Watch Window" => "监视窗口",
|
"Watch Window" => "监视窗口",
|
||||||
"Calculation\nOptions" => "计算\n选项",
|
"Calculation\nOptions" => "计算\n选项",
|
||||||
@@ -331,8 +330,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||||||
"Go to Header" => "转至页眉",
|
"Go to Header" => "转至页眉",
|
||||||
"Different First Page" => "首页不同",
|
"Different First Page" => "首页不同",
|
||||||
"Different Odd & Even Pages" => "奇偶页不同",
|
"Different Odd & Even Pages" => "奇偶页不同",
|
||||||
"Scale with Document" => "随文档缩放",
|
|
||||||
"Align with Page Margins" => "与页边距对齐",
|
|
||||||
"Go to Footer" => "转至页脚",
|
"Go to Footer" => "转至页脚",
|
||||||
"Ruler" => "标尺",
|
"Ruler" => "标尺",
|
||||||
// File › Backstage view.
|
// File › Backstage view.
|
||||||
|
|||||||
+121
-154
@@ -1,13 +1,9 @@
|
|||||||
//! GridCraft's icon set, drawn in code on a 16×16 design grid (original artwork; see
|
//! GridCraft's icon set, drawn in code on a 16×16 design grid (original artwork; see
|
||||||
//! ATTRIBUTION.md). Large ribbon icons use the same drawings at 32×32 with accent colours.
|
//! ATTRIBUTION.md). Large ribbon icons use the same drawings at 32×32 with accent colours.
|
||||||
//! Accent and paper colours come from the theme tokens, so icons follow light and dark mode;
|
|
||||||
//! icons placed on the sheet ([`paint_on_sheet`]) keep the light colours, as the sheet does.
|
|
||||||
|
|
||||||
use egui::epaint::{CubicBezierShape, PathShape, PathStroke};
|
use egui::epaint::{CubicBezierShape, PathShape, PathStroke};
|
||||||
use egui::{Color32, Painter, Pos2, Rect, Stroke, pos2, vec2};
|
use egui::{Color32, Painter, Pos2, Rect, Stroke, pos2, vec2};
|
||||||
|
|
||||||
use crate::theme::{Cat, Tokens};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
pub enum Icon {
|
pub enum Icon {
|
||||||
Paste,
|
Paste,
|
||||||
@@ -188,57 +184,25 @@ impl G<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The colours an icon draws with besides its main colour: the paper inside documents and cells,
|
/// Accent palette for icons (our own).
|
||||||
/// and the category palette for its coloured parts.
|
pub const GREEN: Color32 = Color32::from_rgb(0x10, 0x7C, 0x41);
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
pub const BLUE: Color32 = Color32::from_rgb(0x2B, 0x7C, 0xD3);
|
||||||
struct Palette {
|
pub const RED: Color32 = Color32::from_rgb(0xD1, 0x3B, 0x2F);
|
||||||
paper: Color32,
|
pub const YELLOW: Color32 = Color32::from_rgb(0xF2, 0xC3, 0x2E);
|
||||||
cat: [Color32; 8],
|
pub const ORANGE: Color32 = Color32::from_rgb(0xE8, 0x7D, 0x2B);
|
||||||
}
|
pub const PURPLE: Color32 = Color32::from_rgb(0x88, 0x55, 0xC8);
|
||||||
|
|
||||||
impl Palette {
|
/// Draws `icon` into `rect` (square-ish), in colour `c`.
|
||||||
fn of(t: &Tokens) -> Palette {
|
|
||||||
Palette { paper: t.paper, cat: t.cat }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cat(&self, c: Cat) -> Color32 {
|
|
||||||
self.cat.get(c as usize).copied().unwrap_or(Color32::GRAY)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A pale wash of a category colour over the paper (cell fills inside icons).
|
|
||||||
fn wash(&self, c: Cat) -> Color32 {
|
|
||||||
self.cat(c).gamma_multiply(0.3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Draws `icon` into `rect` (square-ish), in colour `c`, with the current theme's palette.
|
|
||||||
pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||||
paint_with(p, rect, icon, c, &Palette::of(&Tokens::get(p.ctx())));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Draws `icon` on the sheet, which stays white in both themes, so with the light palette.
|
|
||||||
pub fn paint_on_sheet(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
|
||||||
paint_with(p, rect, icon, c, &Palette::of(&Tokens::light()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Draws `icon` into `rect` (square-ish), in colour `c`, with palette `pal`.
|
|
||||||
fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
|
|
||||||
let side = rect.width().min(rect.height());
|
let side = rect.width().min(rect.height());
|
||||||
let r = Rect::from_center_size(rect.center(), vec2(side, side));
|
let r = Rect::from_center_size(rect.center(), vec2(side, side));
|
||||||
let g = G { p, r, s: side / 16.0 };
|
let g = G { p, r, s: side / 16.0 };
|
||||||
let light = c.gamma_multiply(0.45);
|
let light = c.gamma_multiply(0.45);
|
||||||
let paper = pal.paper;
|
|
||||||
let blue = pal.cat(Cat::Blue);
|
|
||||||
let green = pal.cat(Cat::Green);
|
|
||||||
let red = pal.cat(Cat::Red);
|
|
||||||
let orange = pal.cat(Cat::Orange);
|
|
||||||
let purple = pal.cat(Cat::Purple);
|
|
||||||
let yellow = pal.cat(Cat::Amber);
|
|
||||||
match icon {
|
match icon {
|
||||||
Icon::Paste => {
|
Icon::Paste => {
|
||||||
g.rect(3.0, 2.5, 9.0, 12.0, Some(yellow), None);
|
g.rect(3.0, 2.5, 9.0, 12.0, Some(Color32::from_rgb(0xC8, 0x9B, 0x5C)), None);
|
||||||
g.rect(5.0, 1.5, 5.0, 2.5, Some(pal.cat(Cat::Slate)), None);
|
g.rect(5.0, 1.5, 5.0, 2.5, Some(Color32::from_rgb(0x8A, 0x8A, 0x8A)), None);
|
||||||
g.rect(6.5, 6.0, 7.5, 9.0, Some(paper), Some(c));
|
g.rect(6.5, 6.0, 7.5, 9.0, Some(Color32::WHITE), Some(c));
|
||||||
for y in [8.5, 10.5, 12.5] {
|
for y in [8.5, 10.5, 12.5] {
|
||||||
g.stroke(&[(8.0, y), (12.5, y)], light);
|
g.stroke(&[(8.0, y), (12.5, y)], light);
|
||||||
}
|
}
|
||||||
@@ -250,11 +214,11 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
|
|||||||
g.stroke(&[(9.7, 10.6), (4.5, 2.0)], c);
|
g.stroke(&[(9.7, 10.6), (4.5, 2.0)], c);
|
||||||
}
|
}
|
||||||
Icon::Copy => {
|
Icon::Copy => {
|
||||||
g.rect(2.5, 2.5, 7.5, 9.0, Some(paper), Some(c));
|
g.rect(2.5, 2.5, 7.5, 9.0, Some(Color32::WHITE), Some(c));
|
||||||
g.rect(6.0, 5.5, 7.5, 9.0, Some(paper), Some(c));
|
g.rect(6.0, 5.5, 7.5, 9.0, Some(Color32::WHITE), Some(c));
|
||||||
}
|
}
|
||||||
Icon::Brush => {
|
Icon::Brush => {
|
||||||
g.rect(3.0, 2.0, 10.0, 4.0, Some(yellow), Some(c));
|
g.rect(3.0, 2.0, 10.0, 4.0, Some(YELLOW), Some(c));
|
||||||
g.stroke(&[(8.0, 6.0), (8.0, 8.5), (5.0, 8.5), (5.0, 10.0)], c);
|
g.stroke(&[(8.0, 6.0), (8.0, 8.5), (5.0, 8.5), (5.0, 10.0)], c);
|
||||||
g.rect(4.0, 10.0, 2.0, 4.5, Some(c), None);
|
g.rect(4.0, 10.0, 2.0, 4.5, Some(c), None);
|
||||||
}
|
}
|
||||||
@@ -295,7 +259,7 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
|
|||||||
g.line(&[(2.5, 2.5), (13.5, 2.5), (13.5, 13.5), (2.5, 13.5), (2.5, 2.5)], c, if icon == Icon::BorderThick { 2.2 } else { 1.2 });
|
g.line(&[(2.5, 2.5), (13.5, 2.5), (13.5, 13.5), (2.5, 13.5), (2.5, 2.5)], c, if icon == Icon::BorderThick { 2.2 } else { 1.2 });
|
||||||
}
|
}
|
||||||
Icon::Fill => {
|
Icon::Fill => {
|
||||||
g.poly(&[(3.0, 7.0), (8.0, 2.0), (13.0, 7.0), (8.0, 12.0)], paper);
|
g.poly(&[(3.0, 7.0), (8.0, 2.0), (13.0, 7.0), (8.0, 12.0)], Color32::WHITE);
|
||||||
g.line(&[(3.0, 7.0), (8.0, 2.0), (13.0, 7.0), (8.0, 12.0), (3.0, 7.0)], c, 1.1);
|
g.line(&[(3.0, 7.0), (8.0, 2.0), (13.0, 7.0), (8.0, 12.0), (3.0, 7.0)], c, 1.1);
|
||||||
g.circle(13.5, 10.0, 1.2, Some(c), None);
|
g.circle(13.5, 10.0, 1.2, Some(c), None);
|
||||||
}
|
}
|
||||||
@@ -333,16 +297,16 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
|
|||||||
g.stroke(&[(x0, *y), (x1, *y)], c);
|
g.stroke(&[(x0, *y), (x1, *y)], c);
|
||||||
}
|
}
|
||||||
if icon == Icon::IndentInc {
|
if icon == Icon::IndentInc {
|
||||||
g.poly(&[(2.0, 6.0), (5.0, 8.0), (2.0, 10.0)], blue);
|
g.poly(&[(2.0, 6.0), (5.0, 8.0), (2.0, 10.0)], BLUE);
|
||||||
}
|
}
|
||||||
if icon == Icon::IndentDec {
|
if icon == Icon::IndentDec {
|
||||||
g.poly(&[(5.0, 6.0), (2.0, 8.0), (5.0, 10.0)], blue);
|
g.poly(&[(5.0, 6.0), (2.0, 8.0), (5.0, 10.0)], BLUE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Icon::Orientation => {
|
Icon::Orientation => {
|
||||||
g.text(6.0, 9.0, "ab", 8.5, c, false, false);
|
g.text(6.0, 9.0, "ab", 8.5, c, false, false);
|
||||||
g.stroke(&[(9.0, 13.0), (14.0, 3.0)], blue);
|
g.stroke(&[(9.0, 13.0), (14.0, 3.0)], BLUE);
|
||||||
g.poly(&[(14.5, 2.0), (12.0, 4.0), (14.6, 5.2)], blue);
|
g.poly(&[(14.5, 2.0), (12.0, 4.0), (14.6, 5.2)], BLUE);
|
||||||
}
|
}
|
||||||
Icon::Wrap => {
|
Icon::Wrap => {
|
||||||
g.stroke(&[(2.0, 4.0), (14.0, 4.0)], c);
|
g.stroke(&[(2.0, 4.0), (14.0, 4.0)], c);
|
||||||
@@ -351,16 +315,16 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
|
|||||||
[g.pt(12.0, 8.0), g.pt(15.0, 8.0), g.pt(15.0, 12.0), g.pt(11.0, 12.0)],
|
[g.pt(12.0, 8.0), g.pt(15.0, 8.0), g.pt(15.0, 12.0), g.pt(11.0, 12.0)],
|
||||||
false,
|
false,
|
||||||
Color32::TRANSPARENT,
|
Color32::TRANSPARENT,
|
||||||
Stroke::new(1.1, blue),
|
Stroke::new(1.1, BLUE),
|
||||||
));
|
));
|
||||||
g.poly(&[(9.0, 12.0), (11.5, 10.5), (11.5, 13.5)], blue);
|
g.poly(&[(9.0, 12.0), (11.5, 10.5), (11.5, 13.5)], BLUE);
|
||||||
g.stroke(&[(2.0, 12.0), (7.0, 12.0)], c);
|
g.stroke(&[(2.0, 12.0), (7.0, 12.0)], c);
|
||||||
}
|
}
|
||||||
Icon::Merge => {
|
Icon::Merge => {
|
||||||
g.rect(1.5, 3.5, 13.0, 9.0, None, Some(c));
|
g.rect(1.5, 3.5, 13.0, 9.0, None, Some(c));
|
||||||
g.stroke(&[(4.0, 8.0), (12.0, 8.0)], blue);
|
g.stroke(&[(4.0, 8.0), (12.0, 8.0)], BLUE);
|
||||||
g.poly(&[(3.0, 8.0), (5.5, 6.5), (5.5, 9.5)], blue);
|
g.poly(&[(3.0, 8.0), (5.5, 6.5), (5.5, 9.5)], BLUE);
|
||||||
g.poly(&[(13.0, 8.0), (10.5, 6.5), (10.5, 9.5)], blue);
|
g.poly(&[(13.0, 8.0), (10.5, 6.5), (10.5, 9.5)], BLUE);
|
||||||
}
|
}
|
||||||
Icon::Currency => g.text(8.0, 8.5, "$", 12.0, c, false, false),
|
Icon::Currency => g.text(8.0, 8.5, "$", 12.0, c, false, false),
|
||||||
Icon::Percent => g.text(8.0, 8.5, "%", 11.0, c, false, false),
|
Icon::Percent => g.text(8.0, 8.5, "%", 11.0, c, false, false),
|
||||||
@@ -368,69 +332,69 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
|
|||||||
Icon::DecInc => {
|
Icon::DecInc => {
|
||||||
g.text(5.0, 5.0, ".0", 6.5, c, false, false);
|
g.text(5.0, 5.0, ".0", 6.5, c, false, false);
|
||||||
g.text(10.0, 11.0, ".00", 6.5, c, false, false);
|
g.text(10.0, 11.0, ".00", 6.5, c, false, false);
|
||||||
g.stroke(&[(2.0, 12.0), (5.0, 12.0)], blue);
|
g.stroke(&[(2.0, 12.0), (5.0, 12.0)], BLUE);
|
||||||
g.poly(&[(1.0, 12.0), (2.8, 10.6), (2.8, 13.4)], blue);
|
g.poly(&[(1.0, 12.0), (2.8, 10.6), (2.8, 13.4)], BLUE);
|
||||||
}
|
}
|
||||||
Icon::DecDec => {
|
Icon::DecDec => {
|
||||||
g.text(6.0, 5.0, ".00", 6.5, c, false, false);
|
g.text(6.0, 5.0, ".00", 6.5, c, false, false);
|
||||||
g.text(11.0, 11.0, ".0", 6.5, c, false, false);
|
g.text(11.0, 11.0, ".0", 6.5, c, false, false);
|
||||||
g.stroke(&[(2.0, 12.0), (5.5, 12.0)], blue);
|
g.stroke(&[(2.0, 12.0), (5.5, 12.0)], BLUE);
|
||||||
g.poly(&[(7.0, 12.0), (5.2, 10.6), (5.2, 13.4)], blue);
|
g.poly(&[(7.0, 12.0), (5.2, 10.6), (5.2, 13.4)], BLUE);
|
||||||
}
|
}
|
||||||
Icon::CondFormat => {
|
Icon::CondFormat => {
|
||||||
g.rect(2.0, 2.0, 12.0, 12.0, Some(paper), Some(light));
|
g.rect(2.0, 2.0, 12.0, 12.0, Some(Color32::WHITE), Some(light));
|
||||||
g.rect(3.0, 3.5, 4.0, 2.5, Some(red), None);
|
g.rect(3.0, 3.5, 4.0, 2.5, Some(RED), None);
|
||||||
g.rect(3.0, 7.0, 7.0, 2.5, Some(yellow), None);
|
g.rect(3.0, 7.0, 7.0, 2.5, Some(YELLOW), None);
|
||||||
g.rect(3.0, 10.5, 10.0, 2.5, Some(blue), None);
|
g.rect(3.0, 10.5, 10.0, 2.5, Some(BLUE), None);
|
||||||
}
|
}
|
||||||
Icon::FormatTable => {
|
Icon::FormatTable => {
|
||||||
g.rect(2.0, 2.5, 12.0, 11.0, Some(paper), Some(light));
|
g.rect(2.0, 2.5, 12.0, 11.0, Some(Color32::WHITE), Some(light));
|
||||||
g.rect(2.0, 2.5, 12.0, 3.0, Some(blue), None);
|
g.rect(2.0, 2.5, 12.0, 3.0, Some(BLUE), None);
|
||||||
g.rect(2.0, 8.0, 12.0, 2.5, Some(pal.wash(Cat::Blue)), None);
|
g.rect(2.0, 8.0, 12.0, 2.5, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), None);
|
||||||
g.stroke(&[(6.0, 2.5), (6.0, 13.5)], light);
|
g.stroke(&[(6.0, 2.5), (6.0, 13.5)], light);
|
||||||
g.stroke(&[(10.0, 2.5), (10.0, 13.5)], light);
|
g.stroke(&[(10.0, 2.5), (10.0, 13.5)], light);
|
||||||
}
|
}
|
||||||
Icon::CellStyles => {
|
Icon::CellStyles => {
|
||||||
g.rect(2.0, 3.0, 12.0, 4.5, Some(pal.wash(Cat::Green)), Some(light));
|
g.rect(2.0, 3.0, 12.0, 4.5, Some(Color32::from_rgb(0xC6, 0xEF, 0xCE)), Some(light));
|
||||||
g.rect(2.0, 8.5, 12.0, 4.5, Some(pal.wash(Cat::Red)), Some(light));
|
g.rect(2.0, 8.5, 12.0, 4.5, Some(Color32::from_rgb(0xFF, 0xC7, 0xCE)), Some(light));
|
||||||
g.stroke(&[(4.0, 5.2), (9.0, 5.2)], green);
|
g.stroke(&[(4.0, 5.2), (9.0, 5.2)], GREEN);
|
||||||
g.stroke(&[(4.0, 10.7), (9.0, 10.7)], red);
|
g.stroke(&[(4.0, 10.7), (9.0, 10.7)], RED);
|
||||||
}
|
}
|
||||||
Icon::Insert | Icon::Delete | Icon::Format => {
|
Icon::Insert | Icon::Delete | Icon::Format => {
|
||||||
g.rect(2.0, 3.0, 12.0, 10.0, Some(paper), Some(light));
|
g.rect(2.0, 3.0, 12.0, 10.0, Some(Color32::WHITE), Some(light));
|
||||||
g.stroke(&[(2.0, 6.5), (14.0, 6.5)], light);
|
g.stroke(&[(2.0, 6.5), (14.0, 6.5)], light);
|
||||||
g.stroke(&[(2.0, 10.0), (14.0, 10.0)], light);
|
g.stroke(&[(2.0, 10.0), (14.0, 10.0)], light);
|
||||||
g.stroke(&[(6.0, 3.0), (6.0, 13.0)], light);
|
g.stroke(&[(6.0, 3.0), (6.0, 13.0)], light);
|
||||||
match icon {
|
match icon {
|
||||||
Icon::Insert => {
|
Icon::Insert => {
|
||||||
g.rect(6.0, 6.5, 8.0, 3.5, Some(pal.wash(Cat::Blue)), Some(blue));
|
g.rect(6.0, 6.5, 8.0, 3.5, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), Some(BLUE));
|
||||||
g.line(&[(1.0, 8.2), (4.5, 8.2)], green, 1.6);
|
g.line(&[(1.0, 8.2), (4.5, 8.2)], GREEN, 1.6);
|
||||||
g.line(&[(2.75, 6.5), (2.75, 10.0)], green, 1.6);
|
g.line(&[(2.75, 6.5), (2.75, 10.0)], GREEN, 1.6);
|
||||||
}
|
}
|
||||||
Icon::Delete => {
|
Icon::Delete => {
|
||||||
g.line(&[(8.0, 6.0), (13.0, 11.0)], red, 1.6);
|
g.line(&[(8.0, 6.0), (13.0, 11.0)], RED, 1.6);
|
||||||
g.line(&[(13.0, 6.0), (8.0, 11.0)], red, 1.6);
|
g.line(&[(13.0, 6.0), (8.0, 11.0)], RED, 1.6);
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
g.rect(6.0, 6.5, 8.0, 3.5, Some(pal.wash(Cat::Blue)), None);
|
g.rect(6.0, 6.5, 8.0, 3.5, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Icon::Sum => g.text(8.0, 8.0, "Σ", 13.0, c, false, false),
|
Icon::Sum => g.text(8.0, 8.0, "Σ", 13.0, c, false, false),
|
||||||
Icon::FillDown => {
|
Icon::FillDown => {
|
||||||
g.rect(4.0, 2.0, 8.0, 12.0, Some(paper), Some(light));
|
g.rect(4.0, 2.0, 8.0, 12.0, Some(Color32::WHITE), Some(light));
|
||||||
g.stroke(&[(8.0, 4.0), (8.0, 11.0)], blue);
|
g.stroke(&[(8.0, 4.0), (8.0, 11.0)], BLUE);
|
||||||
g.poly(&[(5.5, 9.5), (10.5, 9.5), (8.0, 13.0)], blue);
|
g.poly(&[(5.5, 9.5), (10.5, 9.5), (8.0, 13.0)], BLUE);
|
||||||
}
|
}
|
||||||
Icon::Eraser => {
|
Icon::Eraser => {
|
||||||
g.poly(&[(2.0, 10.0), (8.0, 4.0), (12.0, 8.0), (6.0, 14.0)], red.gamma_multiply(0.55));
|
g.poly(&[(2.0, 10.0), (8.0, 4.0), (12.0, 8.0), (6.0, 14.0)], Color32::from_rgb(0xE9, 0x8A, 0xB8));
|
||||||
g.poly(&[(8.0, 4.0), (10.5, 1.5), (14.5, 5.5), (12.0, 8.0)], red);
|
g.poly(&[(8.0, 4.0), (10.5, 1.5), (14.5, 5.5), (12.0, 8.0)], Color32::from_rgb(0xB6, 0x4E, 0x86));
|
||||||
}
|
}
|
||||||
Icon::SortFilter => {
|
Icon::SortFilter => {
|
||||||
g.text(4.5, 4.5, "A", 6.0, c, false, false);
|
g.text(4.5, 4.5, "A", 6.0, c, false, false);
|
||||||
g.text(4.5, 11.5, "Z", 6.0, c, false, false);
|
g.text(4.5, 11.5, "Z", 6.0, c, false, false);
|
||||||
g.stroke(&[(8.0, 3.0), (8.0, 13.0)], blue);
|
g.stroke(&[(8.0, 3.0), (8.0, 13.0)], BLUE);
|
||||||
g.poly(&[(6.5, 11.0), (9.5, 11.0), (8.0, 13.5)], blue);
|
g.poly(&[(6.5, 11.0), (9.5, 11.0), (8.0, 13.5)], BLUE);
|
||||||
g.poly(&[(10.0, 3.0), (15.0, 3.0), (13.0, 7.5), (13.0, 12.0), (12.0, 11.0), (12.0, 7.5)], light);
|
g.poly(&[(10.0, 3.0), (15.0, 3.0), (13.0, 7.5), (13.0, 12.0), (12.0, 11.0), (12.0, 7.5)], light);
|
||||||
}
|
}
|
||||||
Icon::Find | Icon::Search | Icon::Zoom => {
|
Icon::Find | Icon::Search | Icon::Zoom => {
|
||||||
@@ -502,45 +466,45 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
|
|||||||
}
|
}
|
||||||
Icon::PageBreak => {
|
Icon::PageBreak => {
|
||||||
g.rect(2.5, 2.0, 11.0, 12.0, None, Some(c));
|
g.rect(2.5, 2.0, 11.0, 12.0, None, Some(c));
|
||||||
g.line(&[(2.5, 8.0), (13.5, 8.0)], blue, 1.6);
|
g.line(&[(2.5, 8.0), (13.5, 8.0)], BLUE, 1.6);
|
||||||
}
|
}
|
||||||
Icon::Table | Icon::PivotTable => {
|
Icon::Table | Icon::PivotTable => {
|
||||||
g.rect(2.0, 2.5, 12.0, 11.0, Some(paper), Some(c));
|
g.rect(2.0, 2.5, 12.0, 11.0, Some(Color32::WHITE), Some(c));
|
||||||
g.rect(2.0, 2.5, 12.0, 3.0, Some(if icon == Icon::Table { blue } else { green }), None);
|
g.rect(2.0, 2.5, 12.0, 3.0, Some(if icon == Icon::Table { BLUE } else { GREEN }), None);
|
||||||
g.stroke(&[(6.0, 2.5), (6.0, 13.5)], light);
|
g.stroke(&[(6.0, 2.5), (6.0, 13.5)], light);
|
||||||
g.stroke(&[(2.0, 9.5), (14.0, 9.5)], light);
|
g.stroke(&[(2.0, 9.5), (14.0, 9.5)], light);
|
||||||
if icon == Icon::PivotTable {
|
if icon == Icon::PivotTable {
|
||||||
g.poly(&[(9.0, 7.0), (12.5, 7.0), (12.5, 10.5)], orange);
|
g.poly(&[(9.0, 7.0), (12.5, 7.0), (12.5, 10.5)], ORANGE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Icon::Chart | Icon::ChartBar => {
|
Icon::Chart | Icon::ChartBar => {
|
||||||
g.stroke(&[(2.0, 14.0), (14.0, 14.0)], c);
|
g.stroke(&[(2.0, 14.0), (14.0, 14.0)], c);
|
||||||
g.rect(3.0, 8.0, 2.5, 6.0, Some(blue), None);
|
g.rect(3.0, 8.0, 2.5, 6.0, Some(BLUE), None);
|
||||||
g.rect(6.8, 4.0, 2.5, 10.0, Some(green), None);
|
g.rect(6.8, 4.0, 2.5, 10.0, Some(GREEN), None);
|
||||||
g.rect(10.6, 6.0, 2.5, 8.0, Some(orange), None);
|
g.rect(10.6, 6.0, 2.5, 8.0, Some(ORANGE), None);
|
||||||
}
|
}
|
||||||
Icon::ChartLine | Icon::Sparkline => {
|
Icon::ChartLine | Icon::Sparkline => {
|
||||||
g.stroke(&[(2.0, 14.0), (14.0, 14.0)], light);
|
g.stroke(&[(2.0, 14.0), (14.0, 14.0)], light);
|
||||||
g.line(&[(2.0, 11.0), (5.5, 7.0), (8.5, 9.5), (13.5, 3.5)], blue, 1.5);
|
g.line(&[(2.0, 11.0), (5.5, 7.0), (8.5, 9.5), (13.5, 3.5)], BLUE, 1.5);
|
||||||
}
|
}
|
||||||
Icon::ChartPie => {
|
Icon::ChartPie => {
|
||||||
g.circle(8.0, 8.0, 6.0, Some(blue), None);
|
g.circle(8.0, 8.0, 6.0, Some(BLUE), None);
|
||||||
g.poly(&[(8.0, 8.0), (8.0, 2.0), (12.0, 3.4), (14.0, 8.0)], orange);
|
g.poly(&[(8.0, 8.0), (8.0, 2.0), (12.0, 3.4), (14.0, 8.0)], ORANGE);
|
||||||
}
|
}
|
||||||
Icon::ChartScatter => {
|
Icon::ChartScatter => {
|
||||||
g.stroke(&[(2.0, 2.0), (2.0, 14.0), (14.0, 14.0)], c);
|
g.stroke(&[(2.0, 2.0), (2.0, 14.0), (14.0, 14.0)], c);
|
||||||
for (x, y) in [(5.0, 11.0), (7.0, 8.0), (9.5, 9.5), (11.0, 5.0), (13.0, 4.0)] {
|
for (x, y) in [(5.0, 11.0), (7.0, 8.0), (9.5, 9.5), (11.0, 5.0), (13.0, 4.0)] {
|
||||||
g.circle(x, y, 1.1, Some(blue), None);
|
g.circle(x, y, 1.1, Some(BLUE), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Icon::Picture => {
|
Icon::Picture => {
|
||||||
g.rect(2.0, 3.0, 12.0, 10.0, Some(pal.wash(Cat::Blue)), Some(c));
|
g.rect(2.0, 3.0, 12.0, 10.0, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), Some(c));
|
||||||
g.poly(&[(3.0, 12.0), (7.0, 7.0), (10.0, 10.0), (11.5, 8.5), (13.0, 12.0)], green);
|
g.poly(&[(3.0, 12.0), (7.0, 7.0), (10.0, 10.0), (11.5, 8.5), (13.0, 12.0)], GREEN);
|
||||||
g.circle(11.0, 5.5, 1.2, Some(yellow), None);
|
g.circle(11.0, 5.5, 1.2, Some(YELLOW), None);
|
||||||
}
|
}
|
||||||
Icon::Shapes => {
|
Icon::Shapes => {
|
||||||
g.circle(5.5, 5.5, 3.5, Some(blue), None);
|
g.circle(5.5, 5.5, 3.5, Some(BLUE), None);
|
||||||
g.rect(7.0, 7.0, 7.0, 7.0, Some(orange), None);
|
g.rect(7.0, 7.0, 7.0, 7.0, Some(ORANGE), None);
|
||||||
}
|
}
|
||||||
Icon::Link => {
|
Icon::Link => {
|
||||||
g.line(&[(6.0, 10.0), (10.0, 6.0)], c, 1.2);
|
g.line(&[(6.0, 10.0), (10.0, 6.0)], c, 1.2);
|
||||||
@@ -548,7 +512,10 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
|
|||||||
g.rect(8.0, 4.0, 6.0, 4.0, None, Some(c));
|
g.rect(8.0, 4.0, 6.0, 4.0, None, Some(c));
|
||||||
}
|
}
|
||||||
Icon::Comment | Icon::Note => {
|
Icon::Comment | Icon::Note => {
|
||||||
g.poly(&[(2.0, 3.0), (14.0, 3.0), (14.0, 11.0), (2.0, 11.0)], if icon == Icon::Note { pal.wash(Cat::Amber) } else { paper });
|
g.poly(
|
||||||
|
&[(2.0, 3.0), (14.0, 3.0), (14.0, 11.0), (2.0, 11.0)],
|
||||||
|
if icon == Icon::Note { Color32::from_rgb(0xFF, 0xF2, 0xA8) } else { Color32::WHITE },
|
||||||
|
);
|
||||||
g.line(&[(2.0, 3.0), (14.0, 3.0), (14.0, 11.0), (7.0, 11.0), (4.0, 14.0), (4.0, 11.0), (2.0, 11.0), (2.0, 3.0)], c, 1.1);
|
g.line(&[(2.0, 3.0), (14.0, 3.0), (14.0, 11.0), (7.0, 11.0), (4.0, 14.0), (4.0, 11.0), (2.0, 11.0), (2.0, 3.0)], c, 1.1);
|
||||||
}
|
}
|
||||||
Icon::TextBox => {
|
Icon::TextBox => {
|
||||||
@@ -562,10 +529,10 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
|
|||||||
g.text(8.0, 8.0, "ab", 8.0, c, false, false);
|
g.text(8.0, 8.0, "ab", 8.0, c, false, false);
|
||||||
}
|
}
|
||||||
Icon::Trace => {
|
Icon::Trace => {
|
||||||
g.circle(4.0, 12.0, 1.6, Some(blue), None);
|
g.circle(4.0, 12.0, 1.6, Some(BLUE), None);
|
||||||
g.circle(12.0, 4.0, 1.6, Some(blue), None);
|
g.circle(12.0, 4.0, 1.6, Some(BLUE), None);
|
||||||
g.line(&[(5.0, 11.0), (11.0, 5.0)], blue, 1.2);
|
g.line(&[(5.0, 11.0), (11.0, 5.0)], BLUE, 1.2);
|
||||||
g.poly(&[(12.5, 3.5), (9.0, 4.5), (11.5, 7.0)], blue);
|
g.poly(&[(12.5, 3.5), (9.0, 4.5), (11.5, 7.0)], BLUE);
|
||||||
}
|
}
|
||||||
Icon::Calc => {
|
Icon::Calc => {
|
||||||
g.rect(3.0, 1.5, 10.0, 13.0, None, Some(c));
|
g.rect(3.0, 1.5, 10.0, 13.0, None, Some(c));
|
||||||
@@ -579,23 +546,23 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
|
|||||||
let (a, b) = if icon == Icon::SortAsc { ("A", "Z") } else { ("Z", "A") };
|
let (a, b) = if icon == Icon::SortAsc { ("A", "Z") } else { ("Z", "A") };
|
||||||
g.text(5.0, 4.5, a, 6.5, c, false, false);
|
g.text(5.0, 4.5, a, 6.5, c, false, false);
|
||||||
g.text(5.0, 11.5, b, 6.5, c, false, false);
|
g.text(5.0, 11.5, b, 6.5, c, false, false);
|
||||||
g.stroke(&[(11.0, 2.5), (11.0, 13.0)], blue);
|
g.stroke(&[(11.0, 2.5), (11.0, 13.0)], BLUE);
|
||||||
g.poly(&[(9.0, 11.0), (13.0, 11.0), (11.0, 14.0)], blue);
|
g.poly(&[(9.0, 11.0), (13.0, 11.0), (11.0, 14.0)], BLUE);
|
||||||
}
|
}
|
||||||
Icon::Duplicates => {
|
Icon::Duplicates => {
|
||||||
g.rect(2.0, 2.0, 9.0, 4.0, Some(paper), Some(c));
|
g.rect(2.0, 2.0, 9.0, 4.0, Some(Color32::WHITE), Some(c));
|
||||||
g.rect(5.0, 7.0, 9.0, 4.0, Some(paper), Some(c));
|
g.rect(5.0, 7.0, 9.0, 4.0, Some(Color32::WHITE), Some(c));
|
||||||
g.line(&[(9.0, 12.0), (14.0, 15.0)], red, 1.4);
|
g.line(&[(9.0, 12.0), (14.0, 15.0)], RED, 1.4);
|
||||||
g.line(&[(14.0, 12.0), (9.0, 15.0)], red, 1.4);
|
g.line(&[(14.0, 12.0), (9.0, 15.0)], RED, 1.4);
|
||||||
}
|
}
|
||||||
Icon::TextColumns => {
|
Icon::TextColumns => {
|
||||||
g.rect(1.5, 3.0, 5.5, 10.0, None, Some(c));
|
g.rect(1.5, 3.0, 5.5, 10.0, None, Some(c));
|
||||||
g.rect(9.0, 3.0, 5.5, 10.0, None, Some(c));
|
g.rect(9.0, 3.0, 5.5, 10.0, None, Some(c));
|
||||||
g.poly(&[(7.0, 6.0), (9.0, 8.0), (7.0, 10.0)], blue);
|
g.poly(&[(7.0, 6.0), (9.0, 8.0), (7.0, 10.0)], BLUE);
|
||||||
}
|
}
|
||||||
Icon::Validation => {
|
Icon::Validation => {
|
||||||
g.rect(2.0, 3.0, 12.0, 10.0, None, Some(c));
|
g.rect(2.0, 3.0, 12.0, 10.0, None, Some(c));
|
||||||
g.line(&[(4.0, 8.0), (6.5, 10.5), (11.5, 5.5)], green, 1.4);
|
g.line(&[(4.0, 8.0), (6.5, 10.5), (11.5, 5.5)], GREEN, 1.4);
|
||||||
}
|
}
|
||||||
Icon::Group | Icon::Ungroup | Icon::Subtotal => {
|
Icon::Group | Icon::Ungroup | Icon::Subtotal => {
|
||||||
g.stroke(&[(3.0, 3.0), (3.0, 13.0), (6.0, 13.0)], c);
|
g.stroke(&[(3.0, 3.0), (3.0, 13.0), (6.0, 13.0)], c);
|
||||||
@@ -603,59 +570,59 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
|
|||||||
g.stroke(&[(7.0, y), (14.0, y)], light);
|
g.stroke(&[(7.0, y), (14.0, y)], light);
|
||||||
}
|
}
|
||||||
match icon {
|
match icon {
|
||||||
Icon::Group => g.text(3.0, 2.5, "+", 6.0, blue, true, false),
|
Icon::Group => g.text(3.0, 2.5, "+", 6.0, BLUE, true, false),
|
||||||
Icon::Ungroup => g.text(3.0, 2.5, "−", 6.0, blue, true, false),
|
Icon::Ungroup => g.text(3.0, 2.5, "−", 6.0, BLUE, true, false),
|
||||||
_ => g.text(11.0, 13.0, "Σ", 6.0, blue, false, false),
|
_ => g.text(11.0, 13.0, "Σ", 6.0, BLUE, false, false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Icon::Lock => {
|
Icon::Lock => {
|
||||||
g.rect(3.5, 7.0, 9.0, 7.0, Some(yellow), Some(c));
|
g.rect(3.5, 7.0, 9.0, 7.0, Some(YELLOW), Some(c));
|
||||||
g.line(&[(5.5, 7.0), (5.5, 4.5), (8.0, 2.5), (10.5, 4.5), (10.5, 7.0)], c, 1.2);
|
g.line(&[(5.5, 7.0), (5.5, 4.5), (8.0, 2.5), (10.5, 4.5), (10.5, 7.0)], c, 1.2);
|
||||||
}
|
}
|
||||||
Icon::Spell => {
|
Icon::Spell => {
|
||||||
g.text(7.0, 6.0, "abc", 7.0, c, false, false);
|
g.text(7.0, 6.0, "abc", 7.0, c, false, false);
|
||||||
g.line(&[(4.0, 11.0), (6.5, 13.5), (12.0, 8.5)], green, 1.4);
|
g.line(&[(4.0, 11.0), (6.5, 13.5), (12.0, 8.5)], GREEN, 1.4);
|
||||||
}
|
}
|
||||||
Icon::Freeze => {
|
Icon::Freeze => {
|
||||||
g.rect(2.0, 2.5, 12.0, 11.0, None, Some(c));
|
g.rect(2.0, 2.5, 12.0, 11.0, None, Some(c));
|
||||||
g.rect(2.0, 2.5, 12.0, 3.0, Some(pal.wash(Cat::Blue)), None);
|
g.rect(2.0, 2.5, 12.0, 3.0, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), None);
|
||||||
g.rect(2.0, 2.5, 3.5, 11.0, Some(pal.wash(Cat::Blue)), None);
|
g.rect(2.0, 2.5, 3.5, 11.0, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), None);
|
||||||
g.line(&[(2.0, 5.5), (14.0, 5.5)], blue, 1.2);
|
g.line(&[(2.0, 5.5), (14.0, 5.5)], BLUE, 1.2);
|
||||||
g.line(&[(5.5, 2.5), (5.5, 13.5)], blue, 1.2);
|
g.line(&[(5.5, 2.5), (5.5, 13.5)], BLUE, 1.2);
|
||||||
}
|
}
|
||||||
Icon::Theme => {
|
Icon::Theme => {
|
||||||
g.rect(2.0, 2.0, 5.5, 5.5, Some(blue), None);
|
g.rect(2.0, 2.0, 5.5, 5.5, Some(BLUE), None);
|
||||||
g.rect(8.5, 2.0, 5.5, 5.5, Some(orange), None);
|
g.rect(8.5, 2.0, 5.5, 5.5, Some(ORANGE), None);
|
||||||
g.rect(2.0, 8.5, 5.5, 5.5, Some(green), None);
|
g.rect(2.0, 8.5, 5.5, 5.5, Some(GREEN), None);
|
||||||
g.rect(8.5, 8.5, 5.5, 5.5, Some(purple), None);
|
g.rect(8.5, 8.5, 5.5, 5.5, Some(PURPLE), None);
|
||||||
}
|
}
|
||||||
Icon::Margins => {
|
Icon::Margins => {
|
||||||
g.rect(3.0, 1.5, 10.0, 13.0, None, Some(c));
|
g.rect(3.0, 1.5, 10.0, 13.0, None, Some(c));
|
||||||
g.rect(5.0, 3.5, 6.0, 9.0, None, Some(blue));
|
g.rect(5.0, 3.5, 6.0, 9.0, None, Some(BLUE));
|
||||||
}
|
}
|
||||||
Icon::Orient => {
|
Icon::Orient => {
|
||||||
g.rect(2.0, 4.0, 8.0, 10.0, None, Some(c));
|
g.rect(2.0, 4.0, 8.0, 10.0, None, Some(c));
|
||||||
g.rect(6.0, 2.0, 8.0, 6.0, Some(paper), Some(blue));
|
g.rect(6.0, 2.0, 8.0, 6.0, Some(Color32::WHITE), Some(BLUE));
|
||||||
}
|
}
|
||||||
Icon::SheetRtl => {
|
Icon::SheetRtl => {
|
||||||
// A sheet with an arrow running right to left.
|
// A sheet with an arrow running right to left.
|
||||||
g.rect(2.0, 2.5, 12.0, 8.0, None, Some(c));
|
g.rect(2.0, 2.5, 12.0, 8.0, None, Some(c));
|
||||||
g.stroke(&[(6.0, 2.5), (6.0, 10.5)], light);
|
g.stroke(&[(6.0, 2.5), (6.0, 10.5)], light);
|
||||||
g.stroke(&[(10.0, 2.5), (10.0, 10.5)], light);
|
g.stroke(&[(10.0, 2.5), (10.0, 10.5)], light);
|
||||||
g.line(&[(13.5, 13.0), (4.5, 13.0)], blue, 1.4);
|
g.line(&[(13.5, 13.0), (4.5, 13.0)], BLUE, 1.4);
|
||||||
g.poly(&[(2.0, 13.0), (5.0, 10.5), (5.0, 15.5)], blue);
|
g.poly(&[(2.0, 13.0), (5.0, 10.5), (5.0, 15.5)], BLUE);
|
||||||
}
|
}
|
||||||
Icon::PrintArea => {
|
Icon::PrintArea => {
|
||||||
g.rect(2.5, 2.5, 11.0, 11.0, None, Some(light));
|
g.rect(2.5, 2.5, 11.0, 11.0, None, Some(light));
|
||||||
g.line(&[(4.0, 4.0), (11.0, 4.0), (11.0, 10.0), (4.0, 10.0), (4.0, 4.0)], blue, 1.4);
|
g.line(&[(4.0, 4.0), (11.0, 4.0), (11.0, 10.0), (4.0, 10.0), (4.0, 4.0)], BLUE, 1.4);
|
||||||
}
|
}
|
||||||
Icon::Script => {
|
Icon::Script => {
|
||||||
g.rect(2.0, 2.0, 12.0, 12.0, None, Some(c));
|
g.rect(2.0, 2.0, 12.0, 12.0, None, Some(c));
|
||||||
g.line(&[(4.5, 6.0), (7.0, 8.0), (4.5, 10.0)], green, 1.3);
|
g.line(&[(4.5, 6.0), (7.0, 8.0), (4.5, 10.0)], GREEN, 1.3);
|
||||||
g.stroke(&[(8.0, 10.5), (11.5, 10.5)], c);
|
g.stroke(&[(8.0, 10.5), (11.5, 10.5)], c);
|
||||||
}
|
}
|
||||||
Icon::Pen => {
|
Icon::Pen => {
|
||||||
g.poly(&[(3.0, 13.0), (4.0, 10.0), (11.0, 3.0), (13.0, 5.0), (6.0, 12.0)], blue);
|
g.poly(&[(3.0, 13.0), (4.0, 10.0), (11.0, 3.0), (13.0, 5.0), (6.0, 12.0)], BLUE);
|
||||||
g.stroke(&[(3.0, 13.0), (4.0, 10.0)], c);
|
g.stroke(&[(3.0, 13.0), (4.0, 10.0)], c);
|
||||||
}
|
}
|
||||||
Icon::Share => {
|
Icon::Share => {
|
||||||
@@ -678,18 +645,18 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
|
|||||||
g.poly(&half, c);
|
g.poly(&half, c);
|
||||||
}
|
}
|
||||||
Icon::Book => {
|
Icon::Book => {
|
||||||
g.rect(2.0, 2.0, 12.0, 12.0, Some(green), None);
|
g.rect(2.0, 2.0, 12.0, 12.0, Some(GREEN), None);
|
||||||
g.rect(4.0, 4.0, 8.0, 8.0, Some(paper), None);
|
g.rect(4.0, 4.0, 8.0, 8.0, Some(Color32::WHITE), None);
|
||||||
g.stroke(&[(8.0, 4.0), (8.0, 12.0)], green);
|
g.stroke(&[(8.0, 4.0), (8.0, 12.0)], GREEN);
|
||||||
g.stroke(&[(4.0, 8.0), (12.0, 8.0)], green);
|
g.stroke(&[(4.0, 8.0), (12.0, 8.0)], GREEN);
|
||||||
}
|
}
|
||||||
Icon::Folder => {
|
Icon::Folder => {
|
||||||
g.poly(&[(1.5, 4.0), (6.0, 4.0), (7.5, 5.5), (14.5, 5.5), (14.5, 13.0), (1.5, 13.0)], yellow);
|
g.poly(&[(1.5, 4.0), (6.0, 4.0), (7.5, 5.5), (14.5, 5.5), (14.5, 13.0), (1.5, 13.0)], YELLOW);
|
||||||
}
|
}
|
||||||
Icon::Header | Icon::Footer => {
|
Icon::Header | Icon::Footer => {
|
||||||
g.rect(3.5, 1.5, 9.0, 13.0, None, Some(c));
|
g.rect(3.5, 1.5, 9.0, 13.0, None, Some(c));
|
||||||
let y = if icon == Icon::Header { 2.5 } else { 11.0 };
|
let y = if icon == Icon::Header { 2.5 } else { 11.0 };
|
||||||
g.rect(4.5, y, 7.0, 2.5, Some(blue), None);
|
g.rect(4.5, y, 7.0, 2.5, Some(BLUE), None);
|
||||||
for yy in if icon == Icon::Header { [7.0, 9.5] } else { [4.5, 7.0] } {
|
for yy in if icon == Icon::Header { [7.0, 9.5] } else { [4.5, 7.0] } {
|
||||||
g.stroke(&[(5.5, yy), (10.5, yy)], light);
|
g.stroke(&[(5.5, yy), (10.5, yy)], light);
|
||||||
}
|
}
|
||||||
@@ -698,16 +665,16 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
|
|||||||
if icon == Icon::PageCount {
|
if icon == Icon::PageCount {
|
||||||
g.rect(5.5, 1.0, 8.5, 11.5, None, Some(light));
|
g.rect(5.5, 1.0, 8.5, 11.5, None, Some(light));
|
||||||
}
|
}
|
||||||
g.rect(2.5, 3.0, 8.5, 12.0, Some(paper), Some(c));
|
g.rect(2.5, 3.0, 8.5, 12.0, Some(Color32::WHITE), Some(c));
|
||||||
// A "#" mark.
|
// A "#" mark.
|
||||||
g.stroke(&[(5.3, 6.5), (4.8, 12.5)], blue);
|
g.stroke(&[(5.3, 6.5), (4.8, 12.5)], BLUE);
|
||||||
g.stroke(&[(8.3, 6.5), (7.8, 12.5)], blue);
|
g.stroke(&[(8.3, 6.5), (7.8, 12.5)], BLUE);
|
||||||
g.stroke(&[(4.0, 8.4), (9.6, 8.4)], blue);
|
g.stroke(&[(4.0, 8.4), (9.6, 8.4)], BLUE);
|
||||||
g.stroke(&[(3.8, 10.8), (9.4, 10.8)], blue);
|
g.stroke(&[(3.8, 10.8), (9.4, 10.8)], BLUE);
|
||||||
}
|
}
|
||||||
Icon::Calendar => {
|
Icon::Calendar => {
|
||||||
g.rect(2.0, 3.0, 12.0, 11.0, Some(paper), Some(c));
|
g.rect(2.0, 3.0, 12.0, 11.0, Some(Color32::WHITE), Some(c));
|
||||||
g.rect(2.0, 3.0, 12.0, 3.0, Some(blue), None);
|
g.rect(2.0, 3.0, 12.0, 3.0, Some(BLUE), None);
|
||||||
for (x, y) in [(4.5, 8.5), (7.5, 8.5), (10.5, 8.5), (4.5, 11.5), (7.5, 11.5)] {
|
for (x, y) in [(4.5, 8.5), (7.5, 8.5), (10.5, 8.5), (4.5, 11.5), (7.5, 11.5)] {
|
||||||
g.rect(x - 0.6, y - 0.6, 1.4, 1.4, Some(c), None);
|
g.rect(x - 0.6, y - 0.6, 1.4, 1.4, Some(c), None);
|
||||||
}
|
}
|
||||||
@@ -715,13 +682,13 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
|
|||||||
Icon::Clock => {
|
Icon::Clock => {
|
||||||
let center = g.pt(8.0, 8.0);
|
let center = g.pt(8.0, 8.0);
|
||||||
g.p.circle_stroke(center, 6.0 * g.s, Stroke::new(1.15 * g.s.clamp(1.0, 2.0), c));
|
g.p.circle_stroke(center, 6.0 * g.s, Stroke::new(1.15 * g.s.clamp(1.0, 2.0), c));
|
||||||
g.stroke(&[(8.0, 4.5), (8.0, 8.0), (10.8, 9.6)], blue);
|
g.stroke(&[(8.0, 4.5), (8.0, 8.0), (10.8, 9.6)], BLUE);
|
||||||
}
|
}
|
||||||
Icon::FileName => {
|
Icon::FileName => {
|
||||||
g.poly(&[(3.0, 1.5), (10.0, 1.5), (13.0, 4.5), (13.0, 14.5), (3.0, 14.5)], paper);
|
g.poly(&[(3.0, 1.5), (10.0, 1.5), (13.0, 4.5), (13.0, 14.5), (3.0, 14.5)], Color32::WHITE);
|
||||||
g.line(&[(3.0, 1.5), (10.0, 1.5), (13.0, 4.5), (13.0, 14.5), (3.0, 14.5), (3.0, 1.5)], c, 1.15);
|
g.line(&[(3.0, 1.5), (10.0, 1.5), (13.0, 4.5), (13.0, 14.5), (3.0, 14.5), (3.0, 1.5)], c, 1.15);
|
||||||
g.stroke(&[(10.0, 1.5), (10.0, 4.5), (13.0, 4.5)], c);
|
g.stroke(&[(10.0, 1.5), (10.0, 4.5), (13.0, 4.5)], c);
|
||||||
g.line(&[(5.0, 9.0), (11.0, 9.0)], blue, 1.4);
|
g.line(&[(5.0, 9.0), (11.0, 9.0)], BLUE, 1.4);
|
||||||
g.stroke(&[(5.0, 11.5), (9.0, 11.5)], light);
|
g.stroke(&[(5.0, 11.5), (9.0, 11.5)], light);
|
||||||
}
|
}
|
||||||
Icon::Back => {
|
Icon::Back => {
|
||||||
|
|||||||
@@ -103,8 +103,6 @@ pub fn lookup(key: Key, physical: Option<Key>, m: Modifiers, mac: bool) -> Optio
|
|||||||
L if (cmd || ctrl) && s => run("data.filter"),
|
L if (cmd || ctrl) && s => run("data.filter"),
|
||||||
L if cmd && mac => run("home.alignLeft"),
|
L if cmd && mac => run("home.alignLeft"),
|
||||||
L if ctrl && mac => Shortcut::Dialog("defineName", json!({})),
|
L if ctrl && mac => Shortcut::Dialog("defineName", json!({})),
|
||||||
// Ctrl+Shift+U (Control+Shift+U in Excel for Mac) expands or collapses the formula bar.
|
|
||||||
U if ctrl && s => run("view.expandFormulaBar"),
|
|
||||||
U if ctrl && mac && !s => Shortcut::Edit,
|
U if ctrl && mac && !s => Shortcut::Edit,
|
||||||
L if cmd => run("insert.table"),
|
L if cmd => run("insert.table"),
|
||||||
E if cmd && mac && !s => run("home.alignCenter"),
|
E if cmd && mac && !s => run("home.alignCenter"),
|
||||||
@@ -159,7 +157,6 @@ pub fn lookup(key: Key, physical: Option<Key>, m: Modifiers, mac: bool) -> Optio
|
|||||||
F3 if cmd && mac => Shortcut::Dialog("defineName", json!({})),
|
F3 if cmd && mac => Shortcut::Dialog("defineName", json!({})),
|
||||||
F3 if cmd => Shortcut::Dialog("nameManager", json!({})),
|
F3 if cmd => Shortcut::Dialog("nameManager", json!({})),
|
||||||
F3 if plain && s => Shortcut::Dialog("insertFunction", json!({})),
|
F3 if plain && s => Shortcut::Dialog("insertFunction", json!({})),
|
||||||
F3 if plain => Shortcut::Dialog("pasteName", json!({"shortcut": true})),
|
|
||||||
F4 if cmd && !s => run("file.close"),
|
F4 if cmd && !s => run("file.close"),
|
||||||
F5 if plain && s => Shortcut::Dialog("find", json!({})),
|
F5 if plain && s => Shortcut::Dialog("find", json!({})),
|
||||||
F5 if plain => Shortcut::Dialog("goTo", json!({})),
|
F5 if plain => Shortcut::Dialog("goTo", json!({})),
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
|
|||||||
("MP", Entry::Finish(Action::Command("formulas.tracePrecedents"))), // Excel: Trace Precedents
|
("MP", Entry::Finish(Action::Command("formulas.tracePrecedents"))), // Excel: Trace Precedents
|
||||||
("MQ", Entry::Finish(Action::Control("formulas.moreFunctions"))), // Excel: More Functions Menu; group level: containment(More Functions)
|
("MQ", Entry::Finish(Action::Control("formulas.moreFunctions"))), // Excel: More Functions Menu; group level: containment(More Functions)
|
||||||
("MS", Entry::Continue), // Excel: Use in Formula Menu; group level: containment(Use in Formula)
|
("MS", Entry::Continue), // Excel: Use in Formula Menu; group level: containment(Use in Formula)
|
||||||
("MSP", Entry::Finish(Action::Dialog("pasteName"))), // Excel: Paste Names
|
("MSP", Entry::Finish(Action::Pending("formulas.pasteName"))), // pending; Excel: Paste Names; reserved until the command lands
|
||||||
("MT", Entry::Finish(Action::Control("formulas.text"))), // Excel: Text Menu; group level: containment(Text)
|
("MT", Entry::Finish(Action::Control("formulas.text"))), // Excel: Text Menu; group level: containment(Text)
|
||||||
("MU", Entry::Continue), // Excel: AutoSum Menu; group level:
|
("MU", Entry::Continue), // Excel: AutoSum Menu; group level:
|
||||||
("MUA", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Average; AutoSum: Average (variant TBD)
|
("MUA", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Average; AutoSum: Average (variant TBD)
|
||||||
|
|||||||
@@ -1666,10 +1666,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"seq": "MSP",
|
"seq": "MSP",
|
||||||
"kind": "dialog",
|
"kind": "command",
|
||||||
"target": "pasteName",
|
"target": "formulas.pasteName",
|
||||||
"excel": "Paste Names",
|
"excel": "Paste Names",
|
||||||
"note": ""
|
"note": "reserved until the command lands",
|
||||||
|
"pending": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"seq": "MT",
|
"seq": "MT",
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ pub mod backstage;
|
|||||||
mod border_preview;
|
mod border_preview;
|
||||||
mod cell_pictures;
|
mod cell_pictures;
|
||||||
pub mod chartview;
|
pub mod chartview;
|
||||||
pub mod code_editor;
|
|
||||||
pub mod control;
|
pub mod control;
|
||||||
pub mod credits;
|
pub mod credits;
|
||||||
pub mod dialogs;
|
pub mod dialogs;
|
||||||
@@ -567,7 +566,7 @@ impl SheetApp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into());
|
let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into());
|
||||||
let stem = std::path::Path::new(&title).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book1").to_string();
|
let stem = std::path::Path::new(&title).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
|
||||||
let Some(pick) = &self.services.pick_save else { return };
|
let Some(pick) = &self.services.pick_save else { return };
|
||||||
let Some(path) = pick(&format!("{stem}.pdf")) else { return };
|
let Some(path) = pick(&format!("{stem}.pdf")) else { return };
|
||||||
// The picker may suggest a workbook extension; the export writes a PDF.
|
// The picker may suggest a workbook extension; the export writes a PDF.
|
||||||
@@ -633,10 +632,6 @@ impl SheetApp {
|
|||||||
self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed;
|
self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed;
|
||||||
Ok(Json::Null)
|
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}))
|
|
||||||
}
|
|
||||||
"view.darkMode" => {
|
"view.darkMode" => {
|
||||||
let current = if self.ui.system_theme { self.effective_dark } else { self.ui.dark };
|
let current = if self.ui.system_theme { self.effective_dark } else { self.ui.dark };
|
||||||
self.ui.dark = p.get("on").and_then(Json::as_bool).unwrap_or(!current);
|
self.ui.dark = p.get("on").and_then(Json::as_bool).unwrap_or(!current);
|
||||||
@@ -753,7 +748,6 @@ impl SheetApp {
|
|||||||
});
|
});
|
||||||
self.begin_edit(text, false);
|
self.begin_edit(text, false);
|
||||||
}
|
}
|
||||||
UiRequest::InsertName(name) => dialogs::insert_name(self, &name),
|
|
||||||
UiRequest::OpenUrl(u) => {
|
UiRequest::OpenUrl(u) => {
|
||||||
if let Some(f) = &self.services.open_url {
|
if let Some(f) = &self.services.open_url {
|
||||||
f(&u);
|
f(&u);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
//! The outline band of grouped rows and columns (Data ▸ Group), as in Excel: a strip left of the
|
//! The outline band of grouped rows and columns (Data ▸ Group), as in Excel: a strip left of the
|
||||||
//! row headers and one above the column headers, one lane per outline level, with a bracket per
|
//! row headers and one above the column headers, one lane per outline level, with a bracket per
|
||||||
//! expanded group, a − / + button on its summary line (below / right of the detail, or above /
|
//! expanded group, a − / + button on its summary line (below / right of the detail) and the level
|
||||||
//! left of it when the sheet's outline settings say so) and the level buttons 1…n+1 in the
|
//! buttons 1…n+1 in the corner. The bands exist only while the sheet has an outline.
|
||||||
//! corner. The bands exist only while the sheet has an outline.
|
|
||||||
//!
|
//!
|
||||||
//! Layout and hit-testing are pure functions of [`Geo`] in logical (left-to-right) coordinates,
|
//! Layout and hit-testing are pure functions of [`Geo`] in logical (left-to-right) coordinates,
|
||||||
//! so right-to-left sheets get them mirrored with the rest of the grid. Clicks run commands
|
//! so right-to-left sheets get them mirrored with the rest of the grid. Clicks run commands
|
||||||
@@ -32,12 +31,6 @@ impl Axis {
|
|||||||
pub fn new(lines: &BTreeMap<u32, LineInfo>) -> Axis {
|
pub fn new(lines: &BTreeMap<u32, LineInfo>) -> Axis {
|
||||||
Axis { levels: outline::max_level(lines), groups: outline::groups(lines) }
|
Axis { levels: outline::max_level(lines), groups: outline::groups(lines) }
|
||||||
}
|
}
|
||||||
/// The outline of a sheet's rows (`rows`) or columns, with its summary lines where the
|
|
||||||
/// sheet's outline settings put them.
|
|
||||||
pub fn of(sh: &Sheet, rows: bool) -> Axis {
|
|
||||||
let lines = if rows { &sh.rows } else { &sh.cols };
|
|
||||||
Axis { levels: outline::max_level(lines), groups: outline::groups_with(lines, outline::summary_before(sh, rows)) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One lane's width at this zoom (`zoom` is the sheet zoom, 1.0 = 100%).
|
/// One lane's width at this zoom (`zoom` is the sheet zoom, 1.0 = 100%).
|
||||||
@@ -204,7 +197,7 @@ pub fn layout(geo: &Geo, sh: &Sheet) -> Layout {
|
|||||||
for grp in &axis.groups {
|
for grp in &axis.groups {
|
||||||
let across = dir.lane(step, grp.level);
|
let across = dir.lane(step, grp.level);
|
||||||
// The button on the summary line, if that line is on screen.
|
// The button on the summary line, if that line is on screen.
|
||||||
let button = dir.visible(grp.summary()).map(|(a0, a1)| (a0 + a1) / 2.0).filter(|_| grp.has_summary());
|
let button = dir.visible(grp.summary()).map(|(a0, a1)| (a0 + a1) / 2.0).filter(|_| grp.summary() > grp.end);
|
||||||
if let Some(mid) = button {
|
if let Some(mid) = button {
|
||||||
out.buttons.push(Button { rect: dir.rect(mid, across, side), action: Action::Toggle { rows: dir.rows, group: *grp } });
|
out.buttons.push(Button { rect: dir.rect(mid, across, side), action: Action::Toggle { rows: dir.rows, group: *grp } });
|
||||||
}
|
}
|
||||||
@@ -214,18 +207,6 @@ pub fn layout(geo: &Geo, sh: &Sheet) -> Layout {
|
|||||||
// The bracket, over the part of the detail that is on screen.
|
// The bracket, over the part of the detail that is on screen.
|
||||||
let first = dir.first_visible(grp.start, grp.end);
|
let first = dir.first_visible(grp.start, grp.end);
|
||||||
let Some((first_line, (a0, _))) = first else { continue };
|
let Some((first_line, (a0, _))) = first else { continue };
|
||||||
if grp.summary_before {
|
|
||||||
// From the button down (or across) to the last detail line, with the tick there.
|
|
||||||
let a0 = button.map_or(a0, |mid| mid + side / 2.0);
|
|
||||||
let end = dir.start(grp.end) + dir.size(grp.end);
|
|
||||||
let a1 = end.min(dir.hi);
|
|
||||||
if a1 <= a0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let tick = (end <= dir.hi).then(|| [dir.point(a1 - 0.5, across), dir.point(a1 - 0.5, across + side / 2.0)]);
|
|
||||||
out.brackets.push(Bracket { line: [dir.point(a0, across), dir.point(a1 - 0.5, across)], tick });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let a1 = match button {
|
let a1 = match button {
|
||||||
Some(mid) => mid - side / 2.0,
|
Some(mid) => mid - side / 2.0,
|
||||||
None => dir.start(grp.summary()).min(dir.hi),
|
None => dir.start(grp.summary()).min(dir.hi),
|
||||||
@@ -400,41 +381,6 @@ mod tests {
|
|||||||
assert_eq!(outer.action.label(), "\u{2212}");
|
assert_eq!(outer.action.label(), "\u{2212}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn summaries_above_and_left_take_the_toggles_and_the_bracket_ticks_move_to_the_end() {
|
|
||||||
let mut sh = sheet();
|
|
||||||
sh.summary_below = false;
|
|
||||||
sh.summary_right = false;
|
|
||||||
let g = geo(&sh);
|
|
||||||
let l = layout(&g, &sh);
|
|
||||||
let toggle = |rows: bool, level: u8| {
|
|
||||||
l.buttons.iter().find(|b| matches!(b.action, Action::Toggle { rows: r, group } if r == rows && group.level == level)).unwrap()
|
|
||||||
};
|
|
||||||
// Rows 5:8: the outer − is on row 4 (index 3), the inner one (rows 6:7) on row 5.
|
|
||||||
let outer = toggle(true, 1);
|
|
||||||
assert!(g.cell_rect(&sh, CellRef::new(3, 0)).y_range().contains(outer.rect.center().y));
|
|
||||||
assert!(g.cell_rect(&sh, CellRef::new(4, 0)).y_range().contains(toggle(true, 2).rect.center().y));
|
|
||||||
// Column group C:E: its − is above column B.
|
|
||||||
let col = toggle(false, 1);
|
|
||||||
assert!(g.cell_rect(&sh, CellRef::new(0, 1)).x_range().contains(col.rect.center().x));
|
|
||||||
// The bracket runs from the button to the end of row 8, with the tick there.
|
|
||||||
assert_eq!(l.brackets.len(), 3);
|
|
||||||
let b = l.brackets.iter().find(|b| (b.line[0].x - outer.rect.center().x).abs() < 0.01).unwrap();
|
|
||||||
let row8 = g.cell_rect(&sh, CellRef::new(7, 0));
|
|
||||||
assert!((b.line[0].y - outer.rect.bottom()).abs() < 0.01 && (b.line[1].y - (row8.bottom() - 0.5)).abs() < 1.0);
|
|
||||||
assert!(b.tick.is_some_and(|t| (t[0].y - b.line[1].y).abs() < 0.01 && t[1].x > t[0].x));
|
|
||||||
// A group on the first row has no row above it: no toggle, still a bracket.
|
|
||||||
let mut sh = Sheet::new("S");
|
|
||||||
sh.summary_below = false;
|
|
||||||
for r in 0..=2 {
|
|
||||||
sh.rows.entry(r).or_default().outline = 1;
|
|
||||||
}
|
|
||||||
let g = geo(&sh);
|
|
||||||
let l = layout(&g, &sh);
|
|
||||||
assert!(!l.buttons.iter().any(|b| matches!(b.action, Action::Toggle { .. })));
|
|
||||||
assert_eq!(l.brackets.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn collapsed_groups_show_plus_and_no_bracket() {
|
fn collapsed_groups_show_plus_and_no_bracket() {
|
||||||
let mut sh = sheet();
|
let mut sh = sheet();
|
||||||
|
|||||||
@@ -57,13 +57,12 @@ fn comments(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
if list.is_empty() {
|
if list.is_empty() {
|
||||||
ui.label(egui::RichText::new("No comments on this sheet yet. Select a cell and choose New Comment.").italics());
|
ui.label(egui::RichText::new("No comments on this sheet yet. Select a cell and choose New Comment.").italics());
|
||||||
}
|
}
|
||||||
let t = crate::theme::Tokens::get(ui.ctx());
|
|
||||||
for (cell, c) in list {
|
for (cell, c) in list {
|
||||||
egui::Frame::NONE
|
egui::Frame::NONE
|
||||||
.fill(if c.resolved { t.window } else { t.menu_bg })
|
.fill(if c.resolved { Color32::from_gray(240) } else { Color32::WHITE })
|
||||||
.corner_radius(6.0)
|
.corner_radius(6.0)
|
||||||
.inner_margin(8.0)
|
.inner_margin(8.0)
|
||||||
.stroke(egui::Stroke::new(1.0, t.separator))
|
.stroke(egui::Stroke::new(1.0, Color32::from_gray(215)))
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(ui.available_width());
|
ui.set_width(ui.available_width());
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
@@ -71,14 +70,14 @@ fn comments(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
let _ = app.run("selection.set", json!({"cell": cell}));
|
let _ = app.run("selection.set", json!({"cell": cell}));
|
||||||
app.grid.ensure_visible = true;
|
app.grid.ensure_visible = true;
|
||||||
}
|
}
|
||||||
ui.label(egui::RichText::new(&c.author).color(t.text_dim));
|
ui.label(egui::RichText::new(&c.author).color(Color32::from_gray(90)));
|
||||||
if c.resolved {
|
if c.resolved {
|
||||||
ui.label(egui::RichText::new("Resolved").small().color(t.accent));
|
ui.label(egui::RichText::new("Resolved").small().color(Color32::from_rgb(0x10, 0x7C, 0x41)));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.label(&c.text);
|
ui.label(&c.text);
|
||||||
for (a, txt) in &c.replies {
|
for (a, txt) in &c.replies {
|
||||||
ui.label(egui::RichText::new(format!("↳ {a}: {txt}")).color(t.text));
|
ui.label(egui::RichText::new(format!("↳ {a}: {txt}")).color(Color32::from_gray(60)));
|
||||||
}
|
}
|
||||||
let key = egui::Id::new(("reply", &cell));
|
let key = egui::Id::new(("reply", &cell));
|
||||||
let mut draft: String = ui.ctx().data_mut(|m| m.get_temp(key)).unwrap_or_default();
|
let mut draft: String = ui.ctx().data_mut(|m| m.get_temp(key)).unwrap_or_default();
|
||||||
@@ -240,7 +239,7 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover());
|
let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover());
|
||||||
ui.painter().rect_filled(r, 2.0, col);
|
ui.painter().rect_filled(r, 2.0, col);
|
||||||
ui.label(&name);
|
ui.label(&name);
|
||||||
ui.label(egui::RichText::new(&s.values).small().color(crate::theme::Tokens::get(ui.ctx()).text_dim));
|
ui.label(egui::RichText::new(&s.values).small().color(Color32::from_gray(110)));
|
||||||
ui.menu_button("Fill…", |ui| {
|
ui.menu_button("Fill…", |ui| {
|
||||||
if let Some(c) = crate::widgets::color_palette_fixed(ui, &theme.colors, "Automatic") {
|
if let Some(c) = crate::widgets::color_palette_fixed(ui, &theme.colors, "Automatic") {
|
||||||
app.run_or_alert(
|
app.run_or_alert(
|
||||||
|
|||||||
@@ -182,8 +182,7 @@ fn tr<'a>(app: &SheetApp, s: &'a str) -> &'a str {
|
|||||||
/// Section rectangles (left, centre, right) of a page's header or footer on screen.
|
/// Section rectangles (left, centre, right) of a page's header or footer on screen.
|
||||||
fn section_rects(geo: &Geo, pg: &PageGrid, (i, j): (usize, usize), footer: bool) -> [Rect; 3] {
|
fn section_rects(geo: &Geo, pg: &PageGrid, (i, j): (usize, usize), footer: bool) -> [Rect; 3] {
|
||||||
let (px, py, w, h) = pg.page_rect(i, j);
|
let (px, py, w, h) = pg.page_rect(i, j);
|
||||||
let [_, _, t, b, ..] = pg.margins;
|
let [l, r, t, b, ..] = pg.margins;
|
||||||
let [l, r] = pg.hf_sides;
|
|
||||||
let (y0, y1) = if footer { (py + h - b, py + h) } else { (py, py + t) };
|
let (y0, y1) = if footer { (py + h - b, py + h) } else { (py, py + t) };
|
||||||
let inner = (w - l - r).max(3.0);
|
let inner = (w - l - r).max(3.0);
|
||||||
let third = inner / 3.0;
|
let third = inner / 3.0;
|
||||||
@@ -196,8 +195,8 @@ fn section_rects(geo: &Geo, pg: &PageGrid, (i, j): (usize, usize), footer: bool)
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hf_font(geo: &Geo, pg: &PageGrid, size_pt: Option<f32>, bold: bool) -> FontId {
|
fn hf_font(geo: &Geo, size_pt: Option<f32>, bold: bool) -> FontId {
|
||||||
let px = size_pt.unwrap_or(10.0).clamp(1.0, 72.0) * 96.0 / 72.0 * pg.hf_text_k * geo.z;
|
let px = size_pt.unwrap_or(10.0).clamp(1.0, 72.0) * 96.0 / 72.0 * geo.z;
|
||||||
let px = px.clamp(4.0, 120.0);
|
let px = px.clamp(4.0, 120.0);
|
||||||
if bold { theme::ui_bold(px) } else { theme::ui_font(px) }
|
if bold { theme::ui_bold(px) } else { theme::ui_font(px) }
|
||||||
}
|
}
|
||||||
@@ -236,7 +235,7 @@ pub(crate) fn paint_headers_footers(app: &SheetApp, ui: &egui::Ui, p: &Painter,
|
|||||||
if spec.is_empty() {
|
if spec.is_empty() {
|
||||||
if hovered {
|
if hovered {
|
||||||
let label = if footer { "Add footer" } else { "Add header" };
|
let label = if footer { "Add footer" } else { "Add header" };
|
||||||
p.text(rects[1].center(), Align2::CENTER_CENTER, tr(app, label), hf_font(geo, &pg, Some(10.0), false), Color32::from_gray(120));
|
p.text(rects[1].center(), Align2::CENTER_CENTER, tr(app, label), hf_font(geo, Some(10.0), false), Color32::from_gray(120));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -256,7 +255,7 @@ pub(crate) fn paint_headers_footers(app: &SheetApp, ui: &egui::Ui, p: &Painter,
|
|||||||
if sec.text.trim().is_empty() {
|
if sec.text.trim().is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let font = hf_font(geo, &pg, sec.size, sec.bold);
|
let font = hf_font(geo, sec.size, sec.bold);
|
||||||
let r = rects[k];
|
let r = rects[k];
|
||||||
let (anchor, x) = match k {
|
let (anchor, x) = match k {
|
||||||
0 => (Align2::LEFT_TOP, r.left()),
|
0 => (Align2::LEFT_TOP, r.left()),
|
||||||
@@ -327,9 +326,8 @@ pub fn start_editing(app: &mut SheetApp) {
|
|||||||
app.grid.hf_start = true;
|
app.grid.hf_start = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Turns a Header & Footer option (Different First Page, Different Odd & Even Pages, Scale with
|
/// Turns Different First Page / Different Odd & Even Pages on or off, keeping the edit on the
|
||||||
/// Document, Align with Page Margins) on or off, keeping the edit on the same page (which may
|
/// same page (which may now show another header).
|
||||||
/// now show another header).
|
|
||||||
fn set_option(app: &mut SheetApp, key: &str, on: bool) {
|
fn set_option(app: &mut SheetApp, key: &str, on: bool) {
|
||||||
let Some(e) = app.grid.hf_edit.as_ref() else { return };
|
let Some(e) = app.grid.hf_edit.as_ref() else { return };
|
||||||
let (sheet, page, zone) = (e.sheet, e.page, HfZone { footer: e.footer, section: e.section });
|
let (sheet, page, zone) = (e.sheet, e.page, HfZone { footer: e.footer, section: e.section });
|
||||||
@@ -448,7 +446,7 @@ fn editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid) {
|
|||||||
let rects = section_rects(geo, pg, e.page, e.footer);
|
let rects = section_rects(geo, pg, e.page, e.footer);
|
||||||
let p = ui.painter().with_clip_rect(geo.cells);
|
let p = ui.painter().with_clip_rect(geo.cells);
|
||||||
let t = Tokens::get(ui.ctx());
|
let t = Tokens::get(ui.ctx());
|
||||||
let font = hf_font(geo, pg, Some(10.0), false);
|
let font = hf_font(geo, Some(10.0), false);
|
||||||
let mut switch_to = None;
|
let mut switch_to = None;
|
||||||
for (k, r) in rects.iter().enumerate() {
|
for (k, r) in rects.iter().enumerate() {
|
||||||
if !r.intersects(geo.cells) {
|
if !r.intersects(geo.cells) {
|
||||||
@@ -895,10 +893,11 @@ pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) {
|
|||||||
go_to(app, true);
|
go_to(app, true);
|
||||||
}
|
}
|
||||||
sep(ui);
|
sep(ui);
|
||||||
let (first, odd_even, scale_doc, align) =
|
let (first, odd_even) = app
|
||||||
app.session.active().and_then(|d| d.wb.sheet(app.grid.hf_edit.as_ref().map_or(0, |e| e.sheet))).map_or((false, false, true, true), |s| {
|
.session
|
||||||
(s.print.different_first, s.print.different_odd_even, s.print.hf_scale_with_doc, s.print.hf_align_with_margins)
|
.active()
|
||||||
});
|
.and_then(|d| d.wb.sheet(app.grid.hf_edit.as_ref().map_or(0, |e| e.sheet)).map(|s| (s.print.different_first, s.print.different_odd_even)))
|
||||||
|
.unwrap_or_default();
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
let (mut f, mut o) = (first, odd_even);
|
let (mut f, mut o) = (first, odd_even);
|
||||||
if ui.checkbox(&mut f, tr(app, "Different First Page")).changed() {
|
if ui.checkbox(&mut f, tr(app, "Different First Page")).changed() {
|
||||||
@@ -908,15 +907,6 @@ pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) {
|
|||||||
set_option(app, "differentOddEven", o);
|
set_option(app, "differentOddEven", o);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.vertical(|ui| {
|
|
||||||
let (mut sd, mut am) = (scale_doc, align);
|
|
||||||
if ui.checkbox(&mut sd, tr(app, "Scale with Document")).changed() {
|
|
||||||
set_option(app, "scaleWithDoc", sd);
|
|
||||||
}
|
|
||||||
if ui.checkbox(&mut am, tr(app, "Align with Page Margins")).changed() {
|
|
||||||
set_option(app, "alignWithMargins", am);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sep(ui: &mut egui::Ui) {
|
fn sep(ui: &mut egui::Ui) {
|
||||||
|
|||||||
+60
-211
@@ -7,8 +7,8 @@ use serde_json::json;
|
|||||||
use crate::SheetApp;
|
use crate::SheetApp;
|
||||||
use crate::icons::{self, Icon};
|
use crate::icons::{self, Icon};
|
||||||
use crate::keytips::keytip_table;
|
use crate::keytips::keytip_table;
|
||||||
use crate::theme::{self, Cat, Tokens};
|
use crate::theme::{self, Tokens};
|
||||||
use crate::widgets::{big_button, big_split_button, color_palette, icon_button, small_button, small_split_button, split_button, toggle_button};
|
use crate::widgets::{big_button, color_palette, icon_button, small_button, split_button, toggle_button};
|
||||||
|
|
||||||
pub const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"];
|
pub const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"];
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
ui.painter().rect_filled(r, 8.0, if on { t.accent } else { Color32::TRANSPARENT });
|
ui.painter().rect_filled(r, 8.0, if on { t.accent } else { Color32::TRANSPARENT });
|
||||||
ui.painter().rect_stroke(r, 8.0, Stroke::new(1.0, if on { t.accent } else { t.text_dim }), StrokeKind::Inside);
|
ui.painter().rect_stroke(r, 8.0, Stroke::new(1.0, if on { t.accent } else { t.text_dim }), StrokeKind::Inside);
|
||||||
let knob = if on { pos2(r.right() - 8.0, r.center().y) } else { pos2(r.left() + 8.0, r.center().y) };
|
let knob = if on { pos2(r.right() - 8.0, r.center().y) } else { pos2(r.left() + 8.0, r.center().y) };
|
||||||
ui.painter().circle_filled(knob, 5.0, if on { t.on_accent } else { t.text_dim });
|
ui.painter().circle_filled(knob, 5.0, if on { Color32::WHITE } else { t.text_dim });
|
||||||
if resp.on_hover_text(app.ui.language.tr("AutoSave: save after every change (workbooks saved to a file)")).clicked() {
|
if resp.on_hover_text(app.ui.language.tr("AutoSave: save after every change (workbooks saved to a file)")).clicked() {
|
||||||
app.toggle_autosave();
|
app.toggle_autosave();
|
||||||
}
|
}
|
||||||
@@ -279,122 +279,36 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
if collapsed {
|
if collapsed {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Ribbon card. The row above it can be wider than a narrow window, so the card is kept
|
// Ribbon card.
|
||||||
// within the window (with the margin it has on the left), or its right scroll arrow
|
let card = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), 88.0));
|
||||||
// would be drawn off screen.
|
|
||||||
let left = ui.cursor().min.x;
|
|
||||||
let screen = ui.ctx().content_rect();
|
|
||||||
let fits = (screen.right() - left - (left - screen.left()).max(0.0)).max(0.0);
|
|
||||||
let card = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width().min(fits), 88.0));
|
|
||||||
ui.painter().rect_filled(card, 10.0, t.ribbon);
|
ui.painter().rect_filled(card, 10.0, t.ribbon);
|
||||||
ui.painter().rect_stroke(card, 10.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside);
|
ui.painter().rect_stroke(card, 10.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside);
|
||||||
let inner = card.shrink2(vec2(10.0, 6.0));
|
let inner = card.shrink2(vec2(10.0, 6.0));
|
||||||
ui.scope_builder(egui::UiBuilder::new().max_rect(inner), |ui| {
|
ui.scope_builder(egui::UiBuilder::new().max_rect(inner), |ui| {
|
||||||
// One scroll state per tab: a tab opened after scrolling another starts at its
|
egui::ScrollArea::horizontal().scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden).show(ui, |ui| {
|
||||||
// left edge, and a tab keeps its own offset while others are used.
|
ui.horizontal(|ui| {
|
||||||
let scroll = egui::ScrollArea::horizontal()
|
ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
|
||||||
.id_salt(("ribbon_scroll", app.ui.ribbon_tab.as_str()))
|
match app.ui.ribbon_tab.as_str() {
|
||||||
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
|
"Home" => home(app, ui),
|
||||||
.show(ui, |ui| {
|
"Insert" => insert(app, ui),
|
||||||
ui.horizontal(|ui| {
|
"Draw" => draw(app, ui),
|
||||||
ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
|
"Page Layout" => page_layout(app, ui),
|
||||||
match app.ui.ribbon_tab.as_str() {
|
"Formulas" => formulas(app, ui),
|
||||||
"Home" => home(app, ui),
|
"Data" => data(app, ui),
|
||||||
"Insert" => insert(app, ui),
|
"Review" => review(app, ui),
|
||||||
"Draw" => draw(app, ui),
|
"View" => view(app, ui),
|
||||||
"Page Layout" => page_layout(app, ui),
|
"Automate" => automate(app, ui),
|
||||||
"Formulas" => formulas(app, ui),
|
"Table Design" => table_design(app, ui),
|
||||||
"Data" => data(app, ui),
|
"Chart Design" => chart_design(app, ui),
|
||||||
"Review" => review(app, ui),
|
crate::paper_view::TAB => crate::paper_view::ribbon_tab(app, ui),
|
||||||
"View" => view(app, ui),
|
_ => home(app, ui),
|
||||||
"Automate" => automate(app, ui),
|
}
|
||||||
"Table Design" => table_design(app, ui),
|
|
||||||
"Chart Design" => chart_design(app, ui),
|
|
||||||
crate::paper_view::TAB => crate::paper_view::ribbon_tab(app, ui),
|
|
||||||
_ => home(app, ui),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
scroll_arrows(ui, &t, scroll);
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How much of the active tab's groups the ribbon card shows, recorded each frame for the scroll
|
|
||||||
/// arrows and for tests.
|
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
|
||||||
pub struct RibbonOverflow {
|
|
||||||
/// Width of all the tab's groups.
|
|
||||||
pub content: f32,
|
|
||||||
/// Width of the card's content area.
|
|
||||||
pub visible: f32,
|
|
||||||
/// How far the groups are scrolled.
|
|
||||||
pub offset: f32,
|
|
||||||
/// Height of the tallest group (a column of small buttons) and of the content area.
|
|
||||||
pub content_height: f32,
|
|
||||||
pub visible_height: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RibbonOverflow {
|
|
||||||
fn id() -> egui::Id {
|
|
||||||
egui::Id::new("ribbon_overflow")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Last frame's overflow.
|
|
||||||
pub fn get(ctx: &egui::Context) -> RibbonOverflow {
|
|
||||||
ctx.data(|d| d.get_temp(Self::id())).unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the groups are wider than the card.
|
|
||||||
pub fn overflows(&self) -> bool {
|
|
||||||
self.content > self.visible + 0.5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// When the tab's groups are wider than the card, arrows at its edges scroll them (the mouse wheel
|
|
||||||
/// over the card still does too).
|
|
||||||
fn scroll_arrows(ui: &mut Ui, t: &Tokens, mut scroll: egui::scroll_area::ScrollAreaOutput<()>) {
|
|
||||||
let inner = scroll.inner_rect;
|
|
||||||
let max = (scroll.content_size.x - inner.width()).max(0.0);
|
|
||||||
let overflow = RibbonOverflow {
|
|
||||||
content: scroll.content_size.x,
|
|
||||||
visible: inner.width(),
|
|
||||||
offset: scroll.state.offset.x,
|
|
||||||
content_height: scroll.content_size.y,
|
|
||||||
visible_height: inner.height(),
|
|
||||||
};
|
|
||||||
ui.ctx().data_mut(|d| d.insert_temp(RibbonOverflow::id(), overflow));
|
|
||||||
if !overflow.overflows() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let step = (inner.width() * 0.6).max(60.0);
|
|
||||||
let mut target = None;
|
|
||||||
for (left, show) in [(true, overflow.offset > 0.5), (false, overflow.offset < max - 0.5)] {
|
|
||||||
if !show {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let r = if left {
|
|
||||||
Rect::from_min_size(inner.left_top(), vec2(18.0, inner.height()))
|
|
||||||
} else {
|
|
||||||
Rect::from_min_size(pos2(inner.right() - 18.0, inner.top()), vec2(18.0, inner.height()))
|
|
||||||
};
|
|
||||||
let resp = ui.interact(r, ui.id().with(("ribbon_scroll_arrow", left)), Sense::click());
|
|
||||||
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.ribbon });
|
|
||||||
ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside);
|
|
||||||
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), if left { Icon::Left } else { Icon::Right }, t.text);
|
|
||||||
let name = if left { "Scroll ribbon left" } else { "Scroll ribbon right" };
|
|
||||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name));
|
|
||||||
if resp.on_hover_text(crate::i18n::current(ui.ctx()).tr(name)).clicked() {
|
|
||||||
target = Some(if left { overflow.offset - step } else { overflow.offset + step });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(x) = target {
|
|
||||||
scroll.state.offset.x = x.clamp(0.0, max);
|
|
||||||
scroll.state.store(ui.ctx(), scroll.id);
|
|
||||||
ui.ctx().request_repaint();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The File tab opens the Backstage view (New, Open, Info, Save As, Print, Export, Options…), like
|
/// The File tab opens the Backstage view (New, Open, Info, Save As, Print, Export, Options…), like
|
||||||
/// Excel's; the ribbon's tab and collapsed state are left as they were for when it closes.
|
/// Excel's; the ribbon's tab and collapsed state are left as they were for when it closes.
|
||||||
fn file_menu(app: &mut SheetApp, ui: &mut Ui) {
|
fn file_menu(app: &mut SheetApp, ui: &mut Ui) {
|
||||||
@@ -468,23 +382,6 @@ fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) {
|
|||||||
app.run_or_alert(id, params);
|
app.run_or_alert(id, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many cells Formulas › Error Checking › Circular References lists.
|
|
||||||
const CIRCULAR_MENU_LIMIT: usize = 100;
|
|
||||||
|
|
||||||
/// The cell listed for each circular reference, for the Circular References submenu: each once, in the order
|
|
||||||
/// the calculation found them, as (sheet index, cell, "Sheet!A1").
|
|
||||||
fn circular_references(app: &SheetApp) -> Vec<(usize, String, String)> {
|
|
||||||
let Some(d) = app.session.active() else { return Vec::new() };
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
d.calc
|
|
||||||
.circular
|
|
||||||
.iter()
|
|
||||||
.filter(|k| seen.insert(**k))
|
|
||||||
.filter_map(|(si, c)| d.wb.sheet(*si).map(|sh| (*si, c.a1(), format!("{}!{}", gridcraft_engine::formula::quote_sheet(&sh.name), c.a1()))))
|
|
||||||
.take(CIRCULAR_MENU_LIMIT)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Keytips are painted, not focusable widgets: the grid retains keyboard focus.
|
/// Keytips are painted, not focusable widgets: the grid retains keyboard focus.
|
||||||
fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) {
|
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().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return };
|
||||||
@@ -1167,7 +1064,17 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
if s {
|
if s {
|
||||||
act(app, "formulas.autoSum", json!({}));
|
act(app, "formulas.autoSum", json!({}));
|
||||||
}
|
}
|
||||||
egui::Popup::menu(&sa).show(|ui| autosum_menu(app, ui));
|
egui::Popup::menu(&sa).show(|ui| {
|
||||||
|
for f in ["SUM", "AVERAGE", "COUNT", "MAX", "MIN"] {
|
||||||
|
if ui.button(f).clicked() {
|
||||||
|
act(app, "formulas.autoSum", json!({"function": f}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
if ui.button("More Functions…").clicked() {
|
||||||
|
app.open_dialog("insertFunction", json!({}));
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
let fill = small_button(ui, Icon::FillDown, "", "Fill", true);
|
let fill = small_button(ui, Icon::FillDown, "", "Fill", true);
|
||||||
for sequence in keytip_table::group_extension(
|
for sequence in keytip_table::group_extension(
|
||||||
@@ -1260,20 +1167,6 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The AutoSum menu (Home › Editing and Formulas › Function Library): a function, or the Insert
|
|
||||||
/// Function dialog.
|
|
||||||
fn autosum_menu(app: &mut SheetApp, ui: &mut Ui) {
|
|
||||||
for f in ["SUM", "AVERAGE", "COUNT", "MAX", "MIN"] {
|
|
||||||
if ui.button(f).clicked() {
|
|
||||||
act(app, "formulas.autoSum", json!({"function": f}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ui.separator();
|
|
||||||
if ui.button("More Functions…").clicked() {
|
|
||||||
app.open_dialog("insertFunction", json!({}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn font_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) {
|
fn font_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) {
|
||||||
let name = st.font.name.clone();
|
let name = st.font.name.clone();
|
||||||
let resp = ui.add_sized([150.0, 24.0], egui::Button::new(egui::RichText::new(&name).font(theme::ui_font(12.5))));
|
let resp = ui.add_sized([150.0, 24.0], egui::Button::new(egui::RichText::new(&name).font(theme::ui_font(12.5))));
|
||||||
@@ -1496,7 +1389,7 @@ fn table_gallery(app: &mut SheetApp, ui: &mut Ui, cmd: &str) {
|
|||||||
let (r, resp) = ui.allocate_exact_size(vec2(54.0, 38.0), Sense::click());
|
let (r, resp) = ui.allocate_exact_size(vec2(54.0, 38.0), Sense::click());
|
||||||
paint_table_swatch(ui, r, &wb, &name);
|
paint_table_swatch(ui, r, &wb, &name);
|
||||||
if resp.hovered() {
|
if resp.hovered() {
|
||||||
ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Tokens::get(ui.ctx()).cat(Cat::Orange)), StrokeKind::Outside);
|
ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
|
||||||
}
|
}
|
||||||
if resp.on_hover_text(&name).clicked() {
|
if resp.on_hover_text(&name).clicked() {
|
||||||
act(app, cmd, json!({"style": name}));
|
act(app, cmd, json!({"style": name}));
|
||||||
@@ -1514,8 +1407,7 @@ fn paint_table_swatch(ui: &Ui, r: Rect, wb: &gridcraft_engine::model::Workbook,
|
|||||||
let lk = gridcraft_engine::tables::look(style);
|
let lk = gridcraft_engine::tables::look(style);
|
||||||
let res = |c: Option<gridcraft_engine::model::Color>| c.and_then(|c| c.resolve(&wb.theme)).map(theme::color32);
|
let res = |c: Option<gridcraft_engine::model::Color>| c.and_then(|c| c.resolve(&wb.theme)).map(theme::color32);
|
||||||
let p = ui.painter();
|
let p = ui.painter();
|
||||||
let t = Tokens::get(ui.ctx());
|
p.rect_filled(r, 0.0, Color32::WHITE);
|
||||||
p.rect_filled(r, 0.0, t.grid_bg);
|
|
||||||
let rows = 5;
|
let rows = 5;
|
||||||
let rh = r.height() / rows as f32;
|
let rh = r.height() / rows as f32;
|
||||||
for i in 0..rows {
|
for i in 0..rows {
|
||||||
@@ -1535,15 +1427,14 @@ fn paint_table_swatch(ui: &Ui, r: Rect, wb: &gridcraft_engine::model::Workbook,
|
|||||||
}
|
}
|
||||||
for k in 0..3 {
|
for k in 0..3 {
|
||||||
let x = rr.left() + 4.0 + k as f32 * (r.width() - 8.0) / 3.0;
|
let x = rr.left() + 4.0 + k as f32 * (r.width() - 8.0) / 3.0;
|
||||||
let col = if i == 0 { res(Some(lk.header_font)).unwrap_or(t.cell_text) } else { t.sheet_control_border };
|
let col = if i == 0 { res(Some(lk.header_font)).unwrap_or(Color32::BLACK) } else { Color32::from_gray(120) };
|
||||||
p.line_segment([pos2(x, rr.center().y), pos2(x + 10.0, rr.center().y)], Stroke::new(1.0, col));
|
p.line_segment([pos2(x, rr.center().y), pos2(x + 10.0, rr.center().y)], Stroke::new(1.0, col));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
|
p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_gray(210)), StrokeKind::Inside);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cell_style_gallery(app: &mut SheetApp, ui: &mut Ui) {
|
fn cell_style_gallery(app: &mut SheetApp, ui: &mut Ui) {
|
||||||
let t = Tokens::get(ui.ctx());
|
|
||||||
ui.set_width(520.0);
|
ui.set_width(520.0);
|
||||||
let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return };
|
let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return };
|
||||||
let groups: &[(&str, &[&str])] = &[
|
let groups: &[(&str, &[&str])] = &[
|
||||||
@@ -1593,17 +1484,17 @@ fn cell_style_gallery(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
.resolve(&wb.theme)
|
.resolve(&wb.theme)
|
||||||
.filter(|_| st.fill.pattern != gridcraft_engine::model::PatternType::None)
|
.filter(|_| st.fill.pattern != gridcraft_engine::model::PatternType::None)
|
||||||
.map(theme::color32)
|
.map(theme::color32)
|
||||||
.unwrap_or(t.grid_bg);
|
.unwrap_or(Color32::WHITE);
|
||||||
ui.painter().rect_filled(r, 0.0, fill);
|
ui.painter().rect_filled(r, 0.0, fill);
|
||||||
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
|
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_gray(210)), StrokeKind::Inside);
|
||||||
if !st.borders.bottom.is_none() {
|
if !st.borders.bottom.is_none() {
|
||||||
let c = st.borders.bottom.color.resolve(&wb.theme).map(theme::color32).unwrap_or(t.cell_text);
|
let c = st.borders.bottom.color.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::BLACK);
|
||||||
ui.painter().line_segment(
|
ui.painter().line_segment(
|
||||||
[r.left_bottom() + vec2(0.0, -1.5), r.right_bottom() + vec2(0.0, -1.5)],
|
[r.left_bottom() + vec2(0.0, -1.5), r.right_bottom() + vec2(0.0, -1.5)],
|
||||||
Stroke::new(st.borders.bottom.style.width(), c),
|
Stroke::new(st.borders.bottom.style.width(), c),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let col = st.font.color.resolve(&wb.theme).map(theme::color32).unwrap_or(t.cell_text);
|
let col = st.font.color.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::BLACK);
|
||||||
let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic);
|
let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic);
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
r.left_center() + vec2(4.0, 0.0),
|
r.left_center() + vec2(4.0, 0.0),
|
||||||
@@ -1613,7 +1504,7 @@ fn cell_style_gallery(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
col,
|
col,
|
||||||
);
|
);
|
||||||
if resp.hovered() {
|
if resp.hovered() {
|
||||||
ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Tokens::get(ui.ctx()).cat(Cat::Orange)), StrokeKind::Outside);
|
ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
|
||||||
}
|
}
|
||||||
if resp.clicked() {
|
if resp.clicked() {
|
||||||
act(app, "home.cellStyle", json!({"name": n}));
|
act(app, "home.cellStyle", json!({"name": n}));
|
||||||
@@ -1708,7 +1599,6 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
("100% Stacked Column", "column", "stacked100"),
|
("100% Stacked Column", "column", "stacked100"),
|
||||||
("Clustered Bar", "bar", ""),
|
("Clustered Bar", "bar", ""),
|
||||||
("Stacked Bar", "bar", "stacked"),
|
("Stacked Bar", "bar", "stacked"),
|
||||||
("100% Stacked Bar", "bar", "stacked100"),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -1721,7 +1611,6 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
("Stacked Line", "line", "stacked"),
|
("Stacked Line", "line", "stacked"),
|
||||||
("Area", "area", ""),
|
("Area", "area", ""),
|
||||||
("Stacked Area", "area", "stacked"),
|
("Stacked Area", "area", "stacked"),
|
||||||
("100% Stacked Area", "area", "stacked100"),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(Icon::ChartPie, "Pie or Doughnut", "insert.chart.pie", vec![("Pie", "pie", ""), ("Doughnut", "doughnut", "")]),
|
(Icon::ChartPie, "Pie or Doughnut", "insert.chart.pie", vec![("Pie", "pie", ""), ("Doughnut", "doughnut", "")]),
|
||||||
@@ -2032,12 +1921,11 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
if __k.clicked() {
|
if __k.clicked() {
|
||||||
app.open_dialog("insertFunction", json!({}));
|
app.open_dialog("insertFunction", json!({}));
|
||||||
}
|
}
|
||||||
let (s, sa) = big_split_button(ui, Icon::Sum, "AutoSum", "AutoSum");
|
let s = big_button(ui, Icon::Sum, "AutoSum", "AutoSum", true);
|
||||||
control_keytip(app, ui, &sa, "formulas.autoSum");
|
control_keytip(app, ui, &s, "formulas.autoSum");
|
||||||
if s.clicked() {
|
if s.clicked() {
|
||||||
act(app, "formulas.autoSum", json!({}));
|
act(app, "formulas.autoSum", json!({}));
|
||||||
}
|
}
|
||||||
egui::Popup::menu(&sa).show(|ui| autosum_menu(app, ui));
|
|
||||||
for (label, cat, cid) in [
|
for (label, cat, cid) in [
|
||||||
("Financial", "Financial", "formulas.financial"),
|
("Financial", "Financial", "formulas.financial"),
|
||||||
("Logical", "Logical", "formulas.logical"),
|
("Logical", "Logical", "formulas.logical"),
|
||||||
@@ -2083,36 +1971,14 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
app.open_dialog("defineName", json!({}));
|
app.open_dialog("defineName", json!({}));
|
||||||
}
|
}
|
||||||
let use_in = small_button(ui, Icon::Function, "Use in Formula", "Use in Formula", true);
|
let use_in = small_button(ui, Icon::Function, "Use in Formula", "Use in Formula", true);
|
||||||
anchor_keytip(app, ui, &use_in, &["dialog:pasteName"]);
|
anchor_keytip(app, ui, &use_in, &["formulas.pasteName"]);
|
||||||
egui::Popup::menu(&use_in).show(|ui| {
|
egui::Popup::menu(&use_in).show(|ui| {
|
||||||
// The menu lists the names Paste Names lists; clicking one runs `formulas.useInFormula`, which puts it at the caret.
|
let names: Vec<String> = app.session.active().map(|d| d.wb.names.iter().map(|n| n.name.clone()).collect()).unwrap_or_default();
|
||||||
// Listed when the menu opens, and again only after the workbook changes.
|
for n in names {
|
||||||
let cache = egui::Id::new("gridcraft.use_in_formula_names");
|
if ui.button(&n).clicked() {
|
||||||
let key = app.session.active().map(|d| (d.uid, d.revision, d.wb.active_sheet));
|
let cur = app.editor.as_ref().map(|e| e.text.clone()).unwrap_or_else(|| "=".into());
|
||||||
let cached = ui.ctx().data(|m| m.get_temp::<(Option<(u64, u64, usize)>, std::sync::Arc<Vec<String>>)>(cache)).filter(|(k, _)| *k == key);
|
app.begin_edit(Some(format!("{cur}{n}")), false);
|
||||||
let names = match cached {
|
|
||||||
Some((_, names)) => names,
|
|
||||||
None => {
|
|
||||||
let names: std::sync::Arc<Vec<String>> = std::sync::Arc::new(
|
|
||||||
app.session
|
|
||||||
.active()
|
|
||||||
.map(|d| gridcraft_engine::cmd::formulas::pasteable_names(&d.wb).into_iter().map(|(n, _)| n).collect())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
);
|
|
||||||
ui.ctx().data_mut(|m| m.insert_temp(cache, (key, names.clone())));
|
|
||||||
names
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
for n in names.iter() {
|
|
||||||
if ui.button(n).clicked() {
|
|
||||||
app.run_or_alert("formulas.useInFormula", json!({"name": n}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !names.is_empty() {
|
|
||||||
ui.separator();
|
|
||||||
}
|
|
||||||
if ui.button(app.ui.language.tr("Paste Names…")).clicked() {
|
|
||||||
app.open_dialog("pasteName", json!({}));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let __k = small_button(ui, Icon::Table, "Create from Selection", "Create from Selection", false);
|
let __k = small_button(ui, Icon::Table, "Create from Selection", "Create from Selection", false);
|
||||||
@@ -2143,30 +2009,11 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
if __k.clicked() {
|
if __k.clicked() {
|
||||||
act(app, "formulas.showFormulas", json!({}));
|
act(app, "formulas.showFormulas", json!({}));
|
||||||
}
|
}
|
||||||
let (ec, ec_arrow) = small_split_button(ui, Icon::Validation, "Error Checking", "Error Checking");
|
let __k = small_button(ui, Icon::Validation, "Error Checking", "Error Checking", false);
|
||||||
control_keytip(app, ui, &ec, "dialog:errorChecking");
|
control_keytip(app, ui, &__k, "dialog:errorChecking");
|
||||||
if ec.clicked() {
|
if __k.clicked() {
|
||||||
app.open_dialog("errorChecking", json!({}));
|
app.open_dialog("errorChecking", json!({}));
|
||||||
}
|
}
|
||||||
egui::Popup::menu(&ec_arrow).show(|ui| {
|
|
||||||
let lang = app.ui.language;
|
|
||||||
if ui.button(format!("{}…", lang.tr("Error Checking"))).clicked() {
|
|
||||||
app.open_dialog("errorChecking", json!({}));
|
|
||||||
}
|
|
||||||
let circular = circular_references(app);
|
|
||||||
ui.add_enabled_ui(!circular.is_empty(), |ui| {
|
|
||||||
ui.menu_button(lang.tr("Circular References"), |ui| {
|
|
||||||
egui::ScrollArea::vertical().id_salt("circular_references").max_height(400.0).show(ui, |ui| {
|
|
||||||
for (sheet, cell, label) in circular {
|
|
||||||
if ui.button(label).clicked() {
|
|
||||||
act(app, "selection.set", json!({"range": cell, "sheet": sheet}));
|
|
||||||
app.grid.ensure_visible = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
let __k = small_button(ui, Icon::Calc, "Evaluate Formula", "Evaluate Formula", false);
|
let __k = small_button(ui, Icon::Calc, "Evaluate Formula", "Evaluate Formula", false);
|
||||||
control_keytip(app, ui, &__k, "dialog:evaluateFormula");
|
control_keytip(app, ui, &__k, "dialog:evaluateFormula");
|
||||||
if __k.clicked() {
|
if __k.clicked() {
|
||||||
@@ -2329,9 +2176,6 @@ fn data(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
if small_button(ui, Icon::Minus, "Hide Detail", "Hide Detail", false).clicked() {
|
if small_button(ui, Icon::Minus, "Hide Detail", "Hide Detail", false).clicked() {
|
||||||
act(app, "data.hideDetail", json!({}));
|
act(app, "data.hideDetail", json!({}));
|
||||||
}
|
}
|
||||||
if small_button(ui, Icon::Settings, "Settings", "Outline Settings", false).clicked() {
|
|
||||||
app.open_dialog("outlineSettings", json!({}));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
sep(ui);
|
sep(ui);
|
||||||
if big_button(ui, Icon::ChartBar, "Data\nAnalysis", "Data Analysis", false).clicked() {
|
if big_button(ui, Icon::ChartBar, "Data\nAnalysis", "Data Analysis", false).clicked() {
|
||||||
@@ -2860,6 +2704,11 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn unused(_: &Tokens, _: Icon) {
|
||||||
|
let _ = icons::GREEN;
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod title_bar_tests {
|
mod title_bar_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+38
-178
@@ -55,46 +55,6 @@ fn tab_drop(i: usize, steps: i64, copy: bool) -> Option<serde_json::Value> {
|
|||||||
Some(json!({"sheet": i, "to": to}))
|
Some(json!({"sheet": i, "to": to}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The New sheet (+) button and the gap before it.
|
|
||||||
const NEW_SHEET_WIDTH: f32 = 32.0;
|
|
||||||
/// The handle between the tabs and the horizontal scrollbar.
|
|
||||||
const SPLIT_WIDTH: f32 = 8.0;
|
|
||||||
/// The narrowest the scrollbar track gets when the handle is dragged to the right.
|
|
||||||
const MIN_TRACK: f32 = 100.0;
|
|
||||||
|
|
||||||
/// The share of the tab bar (`bar`: its left edge and width) the tabs and New sheet take with
|
|
||||||
/// the handle dragged to `x`: New sheet stays shown, and the track ending at `right` keeps
|
|
||||||
/// [`MIN_TRACK`].
|
|
||||||
fn dragged_share(x: f32, bar: (f32, f32), right: f32) -> f32 {
|
|
||||||
let (left, width) = bar;
|
|
||||||
if !width.is_finite() || width <= 0.0 || !x.is_finite() {
|
|
||||||
return 0.6;
|
|
||||||
}
|
|
||||||
let min = NEW_SHEET_WIDTH / width;
|
|
||||||
let max = ((right - SPLIT_WIDTH - MIN_TRACK - left) / width).max(min);
|
|
||||||
((x - left) / width).clamp(min, max)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The handle at the left edge of the horizontal scrollbar: dragging it gives the sheet tabs more
|
|
||||||
/// or less of the bar, as in Excel, for the workbook `uid`.
|
|
||||||
fn tab_split_handle(app: &mut SheetApp, ui: &mut Ui, split: Rect, bar: (f32, f32), right: f32, uid: u64, t: &Tokens) {
|
|
||||||
app.grid.tab_split = Some(split);
|
|
||||||
let resp = ui.interact(split, ui.id().with("tab_split"), Sense::drag());
|
|
||||||
if resp.hovered() || resp.dragged() {
|
|
||||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
|
||||||
}
|
|
||||||
if resp.dragged()
|
|
||||||
&& let Some(p) = resp.interact_pointer_pos()
|
|
||||||
{
|
|
||||||
app.grid.tab_shares.insert(uid, dragged_share(p.x - SPLIT_WIDTH / 2.0, bar, right));
|
|
||||||
ui.ctx().request_repaint();
|
|
||||||
}
|
|
||||||
let color = if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled };
|
|
||||||
for dy in [-4.0, 0.0, 4.0] {
|
|
||||||
ui.painter().circle_filled(pos2(split.center().x, split.center().y + dy), 1.0, color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
||||||
let t = Tokens::get(ui.ctx());
|
let t = Tokens::get(ui.ctx());
|
||||||
egui::Panel::bottom("sheet_tabs").exact_size(32.0).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| {
|
egui::Panel::bottom("sheet_tabs").exact_size(32.0).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| {
|
||||||
@@ -121,12 +81,6 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
}
|
}
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
let Some(d) = app.session.active() else { return };
|
let Some(d) = app.session.active() else { return };
|
||||||
let (bar_left, bar_width) = (ui.cursor().left(), ui.available_width());
|
|
||||||
let uid = d.uid;
|
|
||||||
// The track ends where the grid does, so it stops under the vertical scrollbar. A share
|
|
||||||
// dragged in a wider window is held to the same limits as a drag at this width.
|
|
||||||
let bar_right = app.grid.rect.map_or(bar_left + bar_width, |r| r.right()).min(bar_left + bar_width);
|
|
||||||
let share = app.grid.tab_shares.get(&uid).map(|&share| dragged_share(bar_left + bar_width * share, (bar_left, bar_width), bar_right));
|
|
||||||
let sheets: Vec<(usize, String, Option<[u8; 3]>, bool)> =
|
let sheets: Vec<(usize, String, Option<[u8; 3]>, bool)> =
|
||||||
d.wb.sheets
|
d.wb.sheets
|
||||||
.iter()
|
.iter()
|
||||||
@@ -137,12 +91,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
// Keep worksheet tabs in their own horizontally scrollable viewport. As in Excel, the
|
// Keep worksheet tabs in their own horizontally scrollable viewport. As in Excel, the
|
||||||
// tabs get up to 60% of the bar and the grid's horizontal scrollbar the rest; a strip
|
// tabs get up to 60% of the bar and the grid's horizontal scrollbar the rest; a strip
|
||||||
// that needs less shrinks to its tabs, so New sheet (+) sits right after the last one.
|
// that needs less shrinks to its tabs, so New sheet (+) sits right after the last one.
|
||||||
// After the handle at the scrollbar's left edge is dragged, the tabs and New sheet
|
let tabs_width = (ui.available_width() * 0.6).max(100.0);
|
||||||
// take the share it was dragged to.
|
|
||||||
let tabs_width = match share {
|
|
||||||
Some(share) => (bar_width * share - NEW_SHEET_WIDTH).max(0.0),
|
|
||||||
None => (bar_width * 0.6).max(100.0),
|
|
||||||
};
|
|
||||||
// Reveal the selected sheet after navigation or layout changes, but
|
// Reveal the selected sheet after navigation or layout changes, but
|
||||||
// allow browsing other tabs without snapping back on every frame.
|
// allow browsing other tabs without snapping back on every frame.
|
||||||
let reveal_key = egui::Id::new((d.uid, d.wb.active_sheet, &sheets, tabs_width.to_bits()));
|
let reveal_key = egui::Id::new((d.uid, d.wb.active_sheet, &sheets, tabs_width.to_bits()));
|
||||||
@@ -167,7 +116,6 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
egui::ScrollArea::horizontal()
|
egui::ScrollArea::horizontal()
|
||||||
.id_salt("worksheet_tabs_horizontal")
|
.id_salt("worksheet_tabs_horizontal")
|
||||||
.auto_shrink([true, false])
|
.auto_shrink([true, false])
|
||||||
.min_scrolled_width(0.0)
|
|
||||||
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
|
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
if wheel_scroll.abs() > 0.0 {
|
if wheel_scroll.abs() > 0.0 {
|
||||||
@@ -303,13 +251,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
// The track ends where the grid does, so it stops under the vertical scrollbar.
|
// The track ends where the grid does, so it stops under the vertical scrollbar.
|
||||||
let avail = ui.available_rect_before_wrap();
|
let avail = ui.available_rect_before_wrap();
|
||||||
let right = app.grid.rect.map_or(avail.right(), |r| r.right()).min(avail.right());
|
let right = app.grid.rect.map_or(avail.right(), |r| r.right()).min(avail.right());
|
||||||
// The handle sits at the track's left edge. With a dragged share, the room the tabs
|
let track = Rect::from_min_max(pos2(avail.left() + 8.0, avail.center().y - 7.0), pos2(right, avail.center().y + 7.0));
|
||||||
// don't fill stays empty before it.
|
|
||||||
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));
|
|
||||||
crate::grid::horizontal_scrollbar(app, ui, track, &t);
|
crate::grid::horizontal_scrollbar(app, ui, track, &t);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -320,13 +262,6 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
egui::Panel::bottom("status_bar").exact_size(26.0).frame(egui::Frame::NONE.fill(t.status_bar).inner_margin(egui::Margin::symmetric(12, 0))).show(
|
egui::Panel::bottom("status_bar").exact_size(26.0).frame(egui::Frame::NONE.fill(t.status_bar).inner_margin(egui::Margin::symmetric(12, 0))).show(
|
||||||
ui,
|
ui,
|
||||||
|ui| {
|
|ui| {
|
||||||
// A right-click anywhere on the status bar opens the statistics menu, the text included, so its labels are not
|
|
||||||
// selectable. The zoom and view controls sense clicks and handle their own.
|
|
||||||
let background = ui.interact(ui.max_rect(), egui::Id::new("gridcraft.status_bar"), Sense::click());
|
|
||||||
background.context_menu(|ui| status_bar_menu(app, ui));
|
|
||||||
let text = |ui: &mut Ui, rich: egui::RichText| {
|
|
||||||
ui.add(egui::Label::new(rich).selectable(false));
|
|
||||||
};
|
|
||||||
ui.horizontal_centered(|ui| {
|
ui.horizontal_centered(|ui| {
|
||||||
let mode = match app.session.mode {
|
let mode = match app.session.mode {
|
||||||
gridcraft_engine::Mode::Ready => "Ready",
|
gridcraft_engine::Mode::Ready => "Ready",
|
||||||
@@ -334,29 +269,25 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
gridcraft_engine::Mode::Edit => "Edit",
|
gridcraft_engine::Mode::Edit => "Edit",
|
||||||
gridcraft_engine::Mode::Point => "Point",
|
gridcraft_engine::Mode::Point => "Point",
|
||||||
};
|
};
|
||||||
text(ui, egui::RichText::new(app.ui.language.tr(mode)).font(theme::ui_font(12.5)).color(t.text_dim));
|
ui.label(egui::RichText::new(app.ui.language.tr(mode)).font(theme::ui_font(12.5)).color(t.text_dim));
|
||||||
if let Some(d) = app.session.active() {
|
if let Some(d) = app.session.active() {
|
||||||
if d.wb.vba_project.is_some() {
|
if d.wb.vba_project.is_some() {
|
||||||
ui.add_space(12.0);
|
ui.add_space(12.0);
|
||||||
text(ui, egui::RichText::new("Macros preserved (not executed)").font(theme::ui_font(12.5)).color(t.danger));
|
ui.label(egui::RichText::new("Macros preserved (not executed)").font(theme::ui_font(12.5)).color(t.danger));
|
||||||
}
|
}
|
||||||
if d.wb.calc.mode == gridcraft_engine::model::CalcMode::Manual {
|
if d.wb.calc.mode == gridcraft_engine::model::CalcMode::Manual {
|
||||||
ui.add_space(12.0);
|
ui.add_space(12.0);
|
||||||
text(ui, egui::RichText::new(app.ui.language.tr("Calculate")).font(theme::ui_font(12.5)).color(t.text_dim));
|
ui.label(egui::RichText::new(app.ui.language.tr("Calculate")).font(theme::ui_font(12.5)).color(t.text_dim));
|
||||||
}
|
}
|
||||||
if !d.calc.circular.is_empty() {
|
if !d.calc.circular.is_empty() {
|
||||||
ui.add_space(12.0);
|
ui.add_space(12.0);
|
||||||
// Only a loop on the active sheet is named, so the address needs no sheet.
|
let (si, c) = d.calc.circular[0];
|
||||||
let label = match d.calc.circular.iter().find(|(si, _)| *si == d.wb.active_sheet) {
|
let name = d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default();
|
||||||
Some((_, c)) => format!("Circular References: {}", c.a1()),
|
ui.label(egui::RichText::new(format!("Circular References: {name}!{}", c.a1())).font(theme::ui_font(12.5)).color(t.danger));
|
||||||
None => "Circular References".to_owned(),
|
|
||||||
};
|
|
||||||
text(ui, egui::RichText::new(label).font(theme::ui_font(12.5)).color(t.danger));
|
|
||||||
}
|
}
|
||||||
if app.session.clipboard.is_some() && app.editor.is_none() {
|
if app.session.clipboard.is_some() && app.editor.is_none() {
|
||||||
ui.add_space(12.0);
|
ui.add_space(12.0);
|
||||||
text(
|
ui.label(
|
||||||
ui,
|
|
||||||
egui::RichText::new(app.ui.language.tr("Select destination and press Enter or choose Paste"))
|
egui::RichText::new(app.ui.language.tr("Select destination and press Enter or choose Paste"))
|
||||||
.font(theme::ui_font(12.5))
|
.font(theme::ui_font(12.5))
|
||||||
.color(t.text_dim),
|
.color(t.text_dim),
|
||||||
@@ -405,10 +336,34 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ui.add_space(16.0);
|
ui.add_space(16.0);
|
||||||
// The layout runs right to left, so the last statistic is drawn first.
|
// Average / Count / Sum.
|
||||||
for p in selection_statistics(app).into_iter().rev() {
|
if let Some(d) = app.session.active()
|
||||||
text(ui, egui::RichText::new(p).font(theme::ui_font(12.5)).color(t.text_dim));
|
&& let Some(sh) = d.wb.active()
|
||||||
ui.add_space(10.0);
|
&& !d.selection.is_single_cell()
|
||||||
|
{
|
||||||
|
let st = gridcraft_engine::display::stats(sh, &d.selection.ranges);
|
||||||
|
let fmt = |v: f64| {
|
||||||
|
let code = d.wb.styles.get(sh.style_id(d.selection.active)).num_fmt.as_str().to_string();
|
||||||
|
let code = if code == "General" { "#,##0.##########".to_string() } else { code };
|
||||||
|
gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(v), &code, &d.wb)
|
||||||
|
.text
|
||||||
|
.trim_end_matches('.')
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
if st.numeric > 0 {
|
||||||
|
parts.push(format!("Sum: {}", fmt(st.sum)));
|
||||||
|
}
|
||||||
|
if st.count > 0 {
|
||||||
|
parts.push(format!("Count: {}", st.count));
|
||||||
|
}
|
||||||
|
if let Some(a) = st.average {
|
||||||
|
parts.push(format!("Average: {}", fmt(a)));
|
||||||
|
}
|
||||||
|
for p in parts {
|
||||||
|
ui.label(egui::RichText::new(p).font(theme::ui_font(12.5)).color(t.text_dim));
|
||||||
|
ui.add_space(10.0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -416,69 +371,9 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The statistics the status bar shows for a selection of more than one cell, left to right: Average, Count,
|
|
||||||
/// Numerical Count, Minimum, Maximum, Sum. Each one shows when it is turned on (`view.statusBar`)
|
|
||||||
/// and the selection has a value for it.
|
|
||||||
pub(crate) fn selection_statistics(app: &SheetApp) -> Vec<String> {
|
|
||||||
let Some(d) = app.session.active() else { return vec![] };
|
|
||||||
let Some(sh) = d.wb.active() else { return vec![] };
|
|
||||||
if d.selection.is_single_cell() {
|
|
||||||
return vec![];
|
|
||||||
}
|
|
||||||
let on = app.session.prefs.status_bar;
|
|
||||||
let st = gridcraft_engine::display::stats(sh, &d.selection.ranges);
|
|
||||||
let fmt = |v: f64| {
|
|
||||||
let code = d.wb.styles.get(sh.style_id(d.selection.active)).num_fmt.as_str().to_string();
|
|
||||||
let code = if code == "General" { "#,##0.##########".to_string() } else { code };
|
|
||||||
gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(v), &code, &d.wb).text.trim_end_matches('.').to_string()
|
|
||||||
};
|
|
||||||
let mut parts = Vec::new();
|
|
||||||
if on.average
|
|
||||||
&& let Some(a) = st.average
|
|
||||||
{
|
|
||||||
parts.push(format!("Average: {}", fmt(a)));
|
|
||||||
}
|
|
||||||
if on.count && st.count > 0 {
|
|
||||||
parts.push(format!("Count: {}", st.count));
|
|
||||||
}
|
|
||||||
if on.numerical_count && st.count > 0 {
|
|
||||||
parts.push(format!("Numerical Count: {}", st.numeric));
|
|
||||||
}
|
|
||||||
if on.minimum && st.numeric > 0 {
|
|
||||||
parts.push(format!("Minimum: {}", fmt(st.min)));
|
|
||||||
}
|
|
||||||
if on.maximum && st.numeric > 0 {
|
|
||||||
parts.push(format!("Maximum: {}", fmt(st.max)));
|
|
||||||
}
|
|
||||||
if on.sum && st.numeric > 0 {
|
|
||||||
parts.push(format!("Sum: {}", fmt(st.sum)));
|
|
||||||
}
|
|
||||||
parts
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The status bar's context menu: a check item for each statistic, each one running `view.statusBar`.
|
|
||||||
fn status_bar_menu(app: &mut SheetApp, ui: &mut Ui) {
|
|
||||||
ui.set_min_width(180.0);
|
|
||||||
let st = app.session.prefs.status_bar;
|
|
||||||
for (key, label, mut on) in [
|
|
||||||
("average", "Average", st.average),
|
|
||||||
("count", "Count", st.count),
|
|
||||||
("numericalCount", "Numerical Count", st.numerical_count),
|
|
||||||
("minimum", "Minimum", st.minimum),
|
|
||||||
("maximum", "Maximum", st.maximum),
|
|
||||||
("sum", "Sum", st.sum),
|
|
||||||
] {
|
|
||||||
if ui.checkbox(&mut on, app.ui.language.tr(label)).changed() {
|
|
||||||
app.run_or_alert("view.statusBar", json!({ key: on }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{activate_sheet, drop_copies_sheet, navigator_items, rename_needs_focus, tab_drop};
|
||||||
MIN_TRACK, NEW_SHEET_WIDTH, SPLIT_WIDTH, activate_sheet, dragged_share, drop_copies_sheet, navigator_items, rename_needs_focus, tab_drop,
|
|
||||||
};
|
|
||||||
use crate::SheetApp;
|
use crate::SheetApp;
|
||||||
use egui::Modifiers;
|
use egui::Modifiers;
|
||||||
use gridcraft_engine::model::{Sheet, Visibility};
|
use gridcraft_engine::model::{Sheet, Visibility};
|
||||||
@@ -501,30 +396,6 @@ mod tests {
|
|||||||
app.session.active().unwrap().wb.sheets.iter().map(|s| s.name.clone()).collect()
|
app.session.active().unwrap().wb.sheets.iter().map(|s| s.name.clone()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn status_bar_shows_the_statistics_that_are_turned_on_in_order() {
|
|
||||||
let mut session = gridcraft_engine::Session::default();
|
|
||||||
session.new_workbook();
|
|
||||||
let mut app = SheetApp::new(session, Default::default());
|
|
||||||
for (cell, input) in [("A1", "4"), ("A2", "-1"), ("A3", "x"), ("A4", "9")] {
|
|
||||||
app.run("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
|
||||||
}
|
|
||||||
app.run("selection.set", json!({"range": "A1:A5"})).unwrap();
|
|
||||||
assert_eq!(super::selection_statistics(&app), ["Average: 4", "Count: 4", "Sum: 12"]);
|
|
||||||
app.run("view.statusBar", json!({"numericalCount": true, "minimum": true, "maximum": true})).unwrap();
|
|
||||||
assert_eq!(super::selection_statistics(&app), ["Average: 4", "Count: 4", "Numerical Count: 3", "Minimum: -1", "Maximum: 9", "Sum: 12"]);
|
|
||||||
app.run("view.statusBar", json!({"average": false, "count": false, "sum": false})).unwrap();
|
|
||||||
assert_eq!(super::selection_statistics(&app), ["Numerical Count: 3", "Minimum: -1", "Maximum: 9"]);
|
|
||||||
// Text only: Count and Numerical Count, nothing that needs a number.
|
|
||||||
app.run("view.statusBar", json!({"average": true, "count": true, "sum": true})).unwrap();
|
|
||||||
app.run("cell.set", json!({"cell": "B1", "input": "a"})).unwrap();
|
|
||||||
app.run("cell.set", json!({"cell": "B2", "input": "b"})).unwrap();
|
|
||||||
app.run("selection.set", json!({"range": "B1:B2"})).unwrap();
|
|
||||||
assert_eq!(super::selection_statistics(&app), ["Count: 2", "Numerical Count: 0"]);
|
|
||||||
app.run("selection.set", json!({"range": "A1"})).unwrap();
|
|
||||||
assert!(super::selection_statistics(&app).is_empty(), "one cell shows no statistics");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn copy_drop_modifier_is_ctrl_or_cmd_or_option_on_mac() {
|
fn copy_drop_modifier_is_ctrl_or_cmd_or_option_on_mac() {
|
||||||
// egui reports Ctrl as `command` too off macOS, and Cmd as `mac_cmd` and `command` on macOS.
|
// egui reports Ctrl as `command` too off macOS, and Cmd as `mac_cmd` and `command` on macOS.
|
||||||
@@ -588,15 +459,4 @@ mod tests {
|
|||||||
let a1 = gridcraft_engine::core::CellRef::parse("A1").unwrap();
|
let a1 = gridcraft_engine::core::CellRef::parse("A1").unwrap();
|
||||||
assert!(app.session.active().unwrap().wb.sheet(0).unwrap().cell(a1).is_some(), "the value lands on the first sheet");
|
assert!(app.session.active().unwrap().wb.sheet(0).unwrap().cell(a1).is_some(), "the value lands on the first sheet");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_dragged_share_keeps_new_sheet_and_the_scrollbar_shown() {
|
|
||||||
// A 1,000 px bar from x = 100 whose track ends at x = 1,100.
|
|
||||||
let bar = (100.0, 1000.0);
|
|
||||||
assert_eq!(dragged_share(600.0, bar, 1100.0), 0.5);
|
|
||||||
assert_eq!(dragged_share(0.0, bar, 1100.0), NEW_SHEET_WIDTH / 1000.0, "New sheet stays shown");
|
|
||||||
assert_eq!(dragged_share(1100.0, bar, 1100.0), (1000.0 - SPLIT_WIDTH - MIN_TRACK) / 1000.0, "the track keeps its minimum");
|
|
||||||
assert_eq!(dragged_share(f32::NAN, bar, 1100.0), 0.6);
|
|
||||||
assert_eq!(dragged_share(600.0, (100.0, 0.0), 1100.0), 0.6, "an empty bar has the default share");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+173
-364
@@ -4,21 +4,11 @@
|
|||||||
//! operating system at runtime (nothing bundled): the UI uses the platform UI font, cells use the
|
//! operating system at runtime (nothing bundled): the UI uses the platform UI font, cells use the
|
||||||
//! best available match for the workbook font (Calibri/Carlito/Aptos/Arial…) with egui's built-in
|
//! best available match for the workbook font (Calibri/Carlito/Aptos/Arial…) with egui's built-in
|
||||||
//! fonts as the last resort.
|
//! fonts as the last resort.
|
||||||
//!
|
|
||||||
//! Chrome colours belong in [`Tokens`], each with a light and a dark value; colours
|
|
||||||
//! drawn on the sheet keep their light value in both themes, because the sheet stays white. The
|
|
||||||
//! foreground/background pairs are listed in `contrast_tests`, which holds them to WCAG 2 (4.5:1
|
|
||||||
//! for text, 3:1 for glyphs) in both themes: a new token goes there too.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use egui::{Color32, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals};
|
use egui::{Color32, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals};
|
||||||
|
|
||||||
/// The light or dark value of a colour, by mode.
|
|
||||||
fn pick<T>(dark: bool, light: T, dark_value: T) -> T {
|
|
||||||
if dark { dark_value } else { light }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct Tokens {
|
pub struct Tokens {
|
||||||
pub dark: bool,
|
pub dark: bool,
|
||||||
@@ -64,178 +54,102 @@ pub struct Tokens {
|
|||||||
pub notice_warn_border: Color32,
|
pub notice_warn_border: Color32,
|
||||||
pub notice_info_bg: Color32,
|
pub notice_info_bg: Color32,
|
||||||
pub notice_info_border: Color32,
|
pub notice_info_border: Color32,
|
||||||
/// Text and glyphs drawn on an `accent` fill (primary buttons, switched-on toggles).
|
|
||||||
pub on_accent: Color32,
|
|
||||||
/// The category palette, indexed by [`Cat`]: the coloured parts of icons, gallery highlights.
|
|
||||||
pub cat: [Color32; 8],
|
|
||||||
/// The page colour inside icons (documents, cells, clipboards).
|
|
||||||
pub paper: Color32,
|
|
||||||
/// Reference colours for formulas edited in the window-coloured formula bar.
|
|
||||||
pub ref_colors: [Color32; 8],
|
|
||||||
/// Reference colours on the sheet (outlines and the in-cell editor). The sheet stays white.
|
|
||||||
pub sheet_ref_colors: [Color32; 8],
|
|
||||||
/// Code colours in the code editor (and the window-coloured fields it sits in).
|
|
||||||
pub syntax: Syntax,
|
|
||||||
/// The message box warning disc and its glyph.
|
|
||||||
pub warning: Color32,
|
|
||||||
pub on_warning: Color32,
|
|
||||||
/// Controls drawn on the sheet (filter and validation buttons, object handles). They follow
|
|
||||||
/// the sheet, so they are light in both themes.
|
|
||||||
pub sheet_control_bg: Color32,
|
|
||||||
pub sheet_control_border: Color32,
|
|
||||||
pub sheet_control_text: Color32,
|
|
||||||
/// A placeholder fill on the sheet (a picture that has not loaded).
|
|
||||||
pub sheet_track: Color32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The eight category colours, in the order of [`Tokens::cat`].
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
|
||||||
pub enum Cat {
|
|
||||||
Blue,
|
|
||||||
Green,
|
|
||||||
Orange,
|
|
||||||
Red,
|
|
||||||
Purple,
|
|
||||||
Teal,
|
|
||||||
Amber,
|
|
||||||
Slate,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Cat {
|
|
||||||
pub const ALL: [Cat; 8] = [Cat::Blue, Cat::Green, Cat::Orange, Cat::Red, Cat::Purple, Cat::Teal, Cat::Amber, Cat::Slate];
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn rgb(hex: u32) -> Color32 {
|
|
||||||
Color32::from_rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reference colours on the white sheet, the same in both themes (the sheet does not follow the
|
|
||||||
/// theme). The light theme's formula bar is white too, so it uses them as well.
|
|
||||||
const SHEET_REF_COLORS: [Color32; 8] =
|
|
||||||
[rgb(0x2E6FD8), rgb(0xD03C3C), rgb(0x7A4FC9), rgb(0x23803D), rgb(0xB5531A), rgb(0x16798A), rgb(0xB83B8E), rgb(0x77681A)];
|
|
||||||
/// Code colours for one background; plain text uses the surface's own text colour.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
||||||
pub struct Syntax {
|
|
||||||
pub keyword: Color32,
|
|
||||||
pub string: Color32,
|
|
||||||
pub number: Color32,
|
|
||||||
pub comment: Color32,
|
|
||||||
pub builtin: Color32,
|
|
||||||
}
|
|
||||||
|
|
||||||
const LIGHT_SYNTAX: Syntax =
|
|
||||||
Syntax { keyword: rgb(0x0B57B3), string: rgb(0xA3361B), number: rgb(0x157A5A), comment: rgb(0x6B7280), builtin: rgb(0x7B3FB5) };
|
|
||||||
const DARK_SYNTAX: Syntax =
|
|
||||||
Syntax { keyword: rgb(0x6FAEFF), string: rgb(0xF09A7A), number: rgb(0x7FD1A8), comment: rgb(0x9AA3AE), builtin: rgb(0xC69BF2) };
|
|
||||||
const SHEET_CONTROL_BG: Color32 = rgb(0xF3F3F3);
|
|
||||||
const SHEET_CONTROL_BORDER: Color32 = rgb(0x8A8A8A);
|
|
||||||
const SHEET_CONTROL_TEXT: Color32 = rgb(0x1F1F1F);
|
|
||||||
const SHEET_TRACK: Color32 = rgb(0xD6D6D6);
|
|
||||||
|
|
||||||
impl Tokens {
|
impl Tokens {
|
||||||
pub fn light() -> Tokens {
|
pub fn light() -> Tokens {
|
||||||
Tokens::palette(false)
|
Tokens {
|
||||||
|
dark: false,
|
||||||
|
window: Color32::from_rgb(0xF3, 0xF3, 0xF3),
|
||||||
|
ribbon: Color32::from_rgb(0xFF, 0xFF, 0xFF),
|
||||||
|
ribbon_border: Color32::from_rgb(0xE3, 0xE3, 0xE3),
|
||||||
|
text: Color32::from_rgb(0x24, 0x24, 0x24),
|
||||||
|
text_dim: Color32::from_rgb(0x61, 0x61, 0x61),
|
||||||
|
text_disabled: Color32::from_rgb(0xB0, 0xB0, 0xB0),
|
||||||
|
accent: Color32::from_rgb(0x10, 0x7C, 0x41),
|
||||||
|
accent_dark: Color32::from_rgb(0x0B, 0x5E, 0x31),
|
||||||
|
accent_soft: Color32::from_rgb(0xDF, 0xF1, 0xE6),
|
||||||
|
hover: Color32::from_rgb(0xEB, 0xEB, 0xEB),
|
||||||
|
pressed: Color32::from_rgb(0xDD, 0xDD, 0xDD),
|
||||||
|
separator: Color32::from_rgb(0xE0, 0xE0, 0xE0),
|
||||||
|
grid_bg: Color32::WHITE,
|
||||||
|
desk: Color32::from_rgb(0xDE, 0xDE, 0xDE),
|
||||||
|
gridline: Color32::from_rgb(0xE1, 0xE1, 0xE1),
|
||||||
|
header_bg: Color32::from_rgb(0xF8, 0xF8, 0xF8),
|
||||||
|
header_text: Color32::from_rgb(0x42, 0x42, 0x42),
|
||||||
|
header_line: Color32::from_rgb(0xD6, 0xD6, 0xD6),
|
||||||
|
header_sel_bg: Color32::from_rgb(0xE1, 0xE1, 0xE1),
|
||||||
|
header_sel_text: Color32::from_rgb(0x10, 0x7C, 0x41),
|
||||||
|
header_all_bg: Color32::from_rgb(0xC8, 0xE6, 0xD3),
|
||||||
|
sel_fill: Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x1C),
|
||||||
|
sel_border: Color32::from_rgb(0x10, 0x7C, 0x41),
|
||||||
|
input_bg: Color32::WHITE,
|
||||||
|
input_border: Color32::from_rgb(0xD1, 0xD1, 0xD1),
|
||||||
|
tab_bar: Color32::from_rgb(0xF3, 0xF3, 0xF3),
|
||||||
|
tab_active: Color32::WHITE,
|
||||||
|
status_bar: Color32::from_rgb(0xF3, 0xF3, 0xF3),
|
||||||
|
cell_text: Color32::BLACK,
|
||||||
|
menu_bg: Color32::WHITE,
|
||||||
|
shadow: Color32::from_black_alpha(40),
|
||||||
|
danger: Color32::from_rgb(0xC4, 0x2B, 0x1C),
|
||||||
|
error_check_green: Color32::from_rgb(0x1E, 0x8E, 0x3E),
|
||||||
|
warning_fill: Color32::from_rgb(0xF2, 0xB7, 0x05),
|
||||||
|
warning_ink: Color32::BLACK,
|
||||||
|
notice_warn_bg: Color32::from_rgb(0xFF, 0xF4, 0xCE),
|
||||||
|
notice_warn_border: Color32::from_rgb(0xF0, 0xC3, 0x6E),
|
||||||
|
notice_info_bg: Color32::from_rgb(0xEA, 0xF2, 0xFB),
|
||||||
|
notice_info_border: Color32::from_rgb(0xB9, 0xD2, 0xEC),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn dark() -> Tokens {
|
pub fn dark() -> Tokens {
|
||||||
Tokens::palette(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One palette for both modes: each colour is written once, with its light and dark values.
|
|
||||||
fn palette(dark: bool) -> Tokens {
|
|
||||||
Tokens {
|
Tokens {
|
||||||
dark,
|
dark: true,
|
||||||
window: pick(dark, Color32::from_rgb(0xF3, 0xF3, 0xF3), Color32::from_rgb(0x29, 0x29, 0x29)),
|
window: Color32::from_rgb(0x29, 0x29, 0x29),
|
||||||
ribbon: pick(dark, Color32::from_rgb(0xFF, 0xFF, 0xFF), Color32::from_rgb(0x33, 0x33, 0x33)),
|
ribbon: Color32::from_rgb(0x33, 0x33, 0x33),
|
||||||
ribbon_border: pick(dark, Color32::from_rgb(0xE3, 0xE3, 0xE3), Color32::from_rgb(0x40, 0x40, 0x40)),
|
ribbon_border: Color32::from_rgb(0x40, 0x40, 0x40),
|
||||||
text: pick(dark, Color32::from_rgb(0x24, 0x24, 0x24), Color32::from_rgb(0xEE, 0xEE, 0xEE)),
|
text: Color32::from_rgb(0xEE, 0xEE, 0xEE),
|
||||||
text_dim: pick(dark, Color32::from_rgb(0x61, 0x61, 0x61), Color32::from_rgb(0xB0, 0xB0, 0xB0)),
|
text_dim: Color32::from_rgb(0xB0, 0xB0, 0xB0),
|
||||||
text_disabled: pick(dark, Color32::from_rgb(0xB0, 0xB0, 0xB0), Color32::from_rgb(0x70, 0x70, 0x70)),
|
text_disabled: Color32::from_rgb(0x70, 0x70, 0x70),
|
||||||
accent: pick(dark, Color32::from_rgb(0x10, 0x7C, 0x41), Color32::from_rgb(0x5C, 0xC5, 0x87)),
|
accent: Color32::from_rgb(0x5C, 0xC5, 0x87),
|
||||||
accent_dark: pick(dark, Color32::from_rgb(0x0B, 0x5E, 0x31), Color32::from_rgb(0x3F, 0xA8, 0x6C)),
|
accent_dark: Color32::from_rgb(0x3F, 0xA8, 0x6C),
|
||||||
accent_soft: pick(dark, Color32::from_rgb(0xDF, 0xF1, 0xE6), Color32::from_rgb(0x23, 0x45, 0x31)),
|
accent_soft: Color32::from_rgb(0x23, 0x45, 0x31),
|
||||||
hover: pick(dark, Color32::from_rgb(0xEB, 0xEB, 0xEB), Color32::from_rgb(0x42, 0x42, 0x42)),
|
hover: Color32::from_rgb(0x42, 0x42, 0x42),
|
||||||
pressed: pick(dark, Color32::from_rgb(0xDD, 0xDD, 0xDD), Color32::from_rgb(0x4D, 0x4D, 0x4D)),
|
pressed: Color32::from_rgb(0x4D, 0x4D, 0x4D),
|
||||||
separator: pick(dark, Color32::from_rgb(0xE0, 0xE0, 0xE0), Color32::from_rgb(0x48, 0x48, 0x48)),
|
separator: Color32::from_rgb(0x48, 0x48, 0x48),
|
||||||
grid_bg: Color32::WHITE,
|
grid_bg: Color32::WHITE,
|
||||||
desk: pick(dark, Color32::from_rgb(0xDE, 0xDE, 0xDE), Color32::from_rgb(0x3A, 0x3A, 0x3A)),
|
desk: Color32::from_rgb(0x3A, 0x3A, 0x3A),
|
||||||
gridline: Color32::from_rgb(0xE1, 0xE1, 0xE1),
|
gridline: Color32::from_rgb(0xE1, 0xE1, 0xE1),
|
||||||
header_bg: pick(dark, Color32::from_rgb(0xF8, 0xF8, 0xF8), Color32::from_rgb(0x30, 0x30, 0x30)),
|
header_bg: Color32::from_rgb(0x30, 0x30, 0x30),
|
||||||
header_text: pick(dark, Color32::from_rgb(0x42, 0x42, 0x42), Color32::from_rgb(0xCC, 0xCC, 0xCC)),
|
header_text: Color32::from_rgb(0xCC, 0xCC, 0xCC),
|
||||||
header_line: pick(dark, Color32::from_rgb(0xD6, 0xD6, 0xD6), Color32::from_rgb(0x45, 0x45, 0x45)),
|
header_line: Color32::from_rgb(0x45, 0x45, 0x45),
|
||||||
header_sel_bg: pick(dark, Color32::from_rgb(0xE1, 0xE1, 0xE1), Color32::from_rgb(0x45, 0x45, 0x45)),
|
header_sel_bg: Color32::from_rgb(0x45, 0x45, 0x45),
|
||||||
header_sel_text: pick(dark, Color32::from_rgb(0x0B, 0x5E, 0x31), Color32::from_rgb(0x6F, 0xD3, 0x9A)),
|
header_sel_text: Color32::from_rgb(0x5C, 0xC5, 0x87),
|
||||||
header_all_bg: pick(dark, Color32::from_rgb(0xC8, 0xE6, 0xD3), Color32::from_rgb(0x2E, 0x55, 0x3E)),
|
header_all_bg: 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)),
|
sel_fill: Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x24),
|
||||||
sel_border: Color32::from_rgb(0x10, 0x7C, 0x41),
|
sel_border: Color32::from_rgb(0x10, 0x7C, 0x41),
|
||||||
input_bg: pick(dark, Color32::WHITE, Color32::from_rgb(0x1F, 0x1F, 0x1F)),
|
input_bg: Color32::from_rgb(0x1F, 0x1F, 0x1F),
|
||||||
input_border: pick(dark, Color32::from_rgb(0xD1, 0xD1, 0xD1), Color32::from_rgb(0x50, 0x50, 0x50)),
|
input_border: Color32::from_rgb(0x50, 0x50, 0x50),
|
||||||
tab_bar: pick(dark, Color32::from_rgb(0xF3, 0xF3, 0xF3), Color32::from_rgb(0x29, 0x29, 0x29)),
|
tab_bar: Color32::from_rgb(0x29, 0x29, 0x29),
|
||||||
tab_active: pick(dark, Color32::WHITE, Color32::from_rgb(0x3A, 0x3A, 0x3A)),
|
tab_active: Color32::from_rgb(0x3A, 0x3A, 0x3A),
|
||||||
status_bar: pick(dark, Color32::from_rgb(0xF3, 0xF3, 0xF3), Color32::from_rgb(0x29, 0x29, 0x29)),
|
status_bar: Color32::from_rgb(0x29, 0x29, 0x29),
|
||||||
cell_text: Color32::BLACK,
|
cell_text: Color32::BLACK,
|
||||||
menu_bg: pick(dark, Color32::WHITE, Color32::from_rgb(0x30, 0x30, 0x30)),
|
menu_bg: Color32::from_rgb(0x30, 0x30, 0x30),
|
||||||
shadow: pick(dark, Color32::from_black_alpha(40), Color32::from_black_alpha(90)),
|
shadow: Color32::from_black_alpha(90),
|
||||||
danger: pick(dark, Color32::from_rgb(0xC4, 0x2B, 0x1C), Color32::from_rgb(0xFF, 0x95, 0x85)),
|
danger: Color32::from_rgb(0xF1, 0x70, 0x5F),
|
||||||
error_check_green: pick(dark, Color32::from_rgb(0x1E, 0x8E, 0x3E), Color32::from_rgb(0x4C, 0xC2, 0x6B)),
|
error_check_green: Color32::from_rgb(0x4C, 0xC2, 0x6B),
|
||||||
warning_fill: Color32::from_rgb(0xF2, 0xB7, 0x05),
|
warning_fill: Color32::from_rgb(0xF2, 0xB7, 0x05),
|
||||||
warning_ink: Color32::BLACK,
|
warning_ink: Color32::BLACK,
|
||||||
notice_warn_bg: pick(dark, Color32::from_rgb(0xFF, 0xF4, 0xCE), Color32::from_rgb(0x4A, 0x3F, 0x1E)),
|
notice_warn_bg: Color32::from_rgb(0x4A, 0x3F, 0x1E),
|
||||||
notice_warn_border: pick(dark, Color32::from_rgb(0xF0, 0xC3, 0x6E), Color32::from_rgb(0x7A, 0x67, 0x30)),
|
notice_warn_border: Color32::from_rgb(0x7A, 0x67, 0x30),
|
||||||
notice_info_bg: pick(dark, Color32::from_rgb(0xEA, 0xF2, 0xFB), Color32::from_rgb(0x24, 0x33, 0x44)),
|
notice_info_bg: Color32::from_rgb(0x24, 0x33, 0x44),
|
||||||
notice_info_border: pick(dark, Color32::from_rgb(0xB9, 0xD2, 0xEC), Color32::from_rgb(0x3B, 0x55, 0x72)),
|
notice_info_border: Color32::from_rgb(0x3B, 0x55, 0x72),
|
||||||
on_accent: pick(dark, Color32::WHITE, rgb(0x0F2A1A)),
|
|
||||||
cat: pick(
|
|
||||||
dark,
|
|
||||||
[rgb(0x2F6FCF), rgb(0x1E8A4C), rgb(0xC8601A), rgb(0xC8372D), rgb(0x8350C8), rgb(0x13808A), rgb(0x9C6C00), rgb(0x5A6B7D)],
|
|
||||||
[rgb(0x6EA8F7), rgb(0x5CC587), rgb(0xF0965A), rgb(0xF07B6E), rgb(0xB394EE), rgb(0x4CC7CF), rgb(0xF2C14E), rgb(0xA9B6C4)],
|
|
||||||
),
|
|
||||||
paper: pick(dark, Color32::WHITE, rgb(0x454545)),
|
|
||||||
ref_colors: pick(
|
|
||||||
dark,
|
|
||||||
SHEET_REF_COLORS,
|
|
||||||
[rgb(0x6FA3F5), rgb(0xF27C7C), rgb(0xB394EE), rgb(0x6CCB85), rgb(0xF0A061), rgb(0x5CC9DA), rgb(0xE77FC4), rgb(0xD4C25A)],
|
|
||||||
),
|
|
||||||
sheet_ref_colors: SHEET_REF_COLORS,
|
|
||||||
syntax: pick(dark, LIGHT_SYNTAX, DARK_SYNTAX),
|
|
||||||
warning: rgb(0xF2C32E),
|
|
||||||
on_warning: rgb(0x1F1F1F),
|
|
||||||
sheet_control_bg: SHEET_CONTROL_BG,
|
|
||||||
sheet_control_border: SHEET_CONTROL_BORDER,
|
|
||||||
sheet_control_text: SHEET_CONTROL_TEXT,
|
|
||||||
sheet_track: SHEET_TRACK,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get(ctx: &egui::Context) -> Tokens {
|
pub fn get(ctx: &egui::Context) -> Tokens {
|
||||||
if ctx.global_style().visuals.dark_mode { Tokens::dark() } else { Tokens::light() }
|
if ctx.global_style().visuals.dark_mode { Tokens::dark() } else { Tokens::light() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One colour of the category palette.
|
|
||||||
pub fn cat(&self, c: Cat) -> Color32 {
|
|
||||||
self.cat.get(c as usize).copied().unwrap_or(self.text)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The reference colour for a formula's `index`-th reference, on the bar or on the sheet.
|
|
||||||
pub fn ref_color(&self, index: usize, on_sheet: bool) -> Color32 {
|
|
||||||
let set = if on_sheet { &self.sheet_ref_colors } else { &self.ref_colors };
|
|
||||||
set.get(index % set.len()).copied().unwrap_or(self.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// WCAG 2 relative luminance of an opaque colour.
|
|
||||||
pub fn luminance(c: Color32) -> f32 {
|
|
||||||
let lin = |v: u8| {
|
|
||||||
let v = f32::from(v) / 255.0;
|
|
||||||
if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
|
||||||
};
|
|
||||||
0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// WCAG 2 contrast ratio between two opaque colours (1 to 21).
|
|
||||||
pub fn contrast(a: Color32, b: Color32) -> f32 {
|
|
||||||
let (la, lb) = (luminance(a), luminance(b));
|
|
||||||
(la.max(lb) + 0.05) / (la.min(lb) + 0.05)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const UI: &str = "ui";
|
pub const UI: &str = "ui";
|
||||||
@@ -347,7 +261,7 @@ fn try_load(paths: &[(&str, u32)]) -> Option<FontData> {
|
|||||||
/// looked for again next time.
|
/// looked for again next time.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn font_file(path: &str, index: u32) -> Option<&'static [u8]> {
|
fn font_file(path: &str, index: u32) -> Option<&'static [u8]> {
|
||||||
static FILES: std::sync::Mutex<std::collections::BTreeMap<String, &[u8]>> = std::sync::Mutex::new(std::collections::BTreeMap::new());
|
static FILES: std::sync::Mutex<std::collections::BTreeMap<String, &'static [u8]>> = std::sync::Mutex::new(std::collections::BTreeMap::new());
|
||||||
let mut files = FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
let mut files = FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
if let Some(&bytes) = files.get(path) {
|
if let Some(&bytes) = files.get(path) {
|
||||||
return font_parses(bytes, index).then_some(bytes);
|
return font_parses(bytes, index).then_some(bytes);
|
||||||
@@ -387,12 +301,6 @@ fn home_fonts(file: &str) -> String {
|
|||||||
format!("{home}/Library/Fonts/{file}")
|
format!("{home}/Library/Fonts/{file}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every file in `files` under every directory in `dirs`, directory first, in the same order a
|
|
||||||
/// nested loop over the directories would add them.
|
|
||||||
fn in_dirs(dirs: &[&str], files: &[&str]) -> Vec<String> {
|
|
||||||
dirs.iter().flat_map(|d| files.iter().map(move |f| format!("{d}/{f}"))).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Candidate font files per role, in preference order.
|
/// Candidate font files per role, in preference order.
|
||||||
fn candidates(role: &str) -> Vec<(String, u32)> {
|
fn candidates(role: &str) -> Vec<(String, u32)> {
|
||||||
let mac_sys = "/System/Library/Fonts";
|
let mac_sys = "/System/Library/Fonts";
|
||||||
@@ -407,11 +315,11 @@ fn candidates(role: &str) -> Vec<(String, u32)> {
|
|||||||
add(format!("{mac_sys}/SFNS.ttf"), 0);
|
add(format!("{mac_sys}/SFNS.ttf"), 0);
|
||||||
add(format!("{mac_sys}/HelveticaNeue.ttc"), 0);
|
add(format!("{mac_sys}/HelveticaNeue.ttc"), 0);
|
||||||
add(format!("{win}\\segoeui.ttf"), 0);
|
add(format!("{win}\\segoeui.ttf"), 0);
|
||||||
for p in in_dirs(
|
for d in lin {
|
||||||
&lin,
|
add(format!("{d}/cantarell/Cantarell-Regular.otf"), 0);
|
||||||
&["cantarell/Cantarell-Regular.otf", "noto/NotoSans-Regular.ttf", "dejavu/DejaVuSans.ttf", "liberation/LiberationSans-Regular.ttf"],
|
add(format!("{d}/noto/NotoSans-Regular.ttf"), 0);
|
||||||
) {
|
add(format!("{d}/dejavu/DejaVuSans.ttf"), 0);
|
||||||
add(p, 0);
|
add(format!("{d}/liberation/LiberationSans-Regular.ttf"), 0);
|
||||||
}
|
}
|
||||||
// Fedora keeps each family in its own package folder (#54).
|
// Fedora keeps each family in its own package folder (#54).
|
||||||
add("/usr/share/fonts/abattis-cantarell-fonts/Cantarell-Regular.otf".into(), 0);
|
add("/usr/share/fonts/abattis-cantarell-fonts/Cantarell-Regular.otf".into(), 0);
|
||||||
@@ -422,8 +330,10 @@ fn candidates(role: &str) -> Vec<(String, u32)> {
|
|||||||
"ui-bold" => {
|
"ui-bold" => {
|
||||||
add(format!("{mac_sys}/HelveticaNeue.ttc"), 1);
|
add(format!("{mac_sys}/HelveticaNeue.ttc"), 1);
|
||||||
add(format!("{win}\\segoeuib.ttf"), 0);
|
add(format!("{win}\\segoeuib.ttf"), 0);
|
||||||
for p in in_dirs(&lin, &["noto/NotoSans-Bold.ttf", "dejavu/DejaVuSans-Bold.ttf", "liberation/LiberationSans-Bold.ttf"]) {
|
for d in lin {
|
||||||
add(p, 0);
|
add(format!("{d}/noto/NotoSans-Bold.ttf"), 0);
|
||||||
|
add(format!("{d}/dejavu/DejaVuSans-Bold.ttf"), 0);
|
||||||
|
add(format!("{d}/liberation/LiberationSans-Bold.ttf"), 0);
|
||||||
}
|
}
|
||||||
add("/usr/share/fonts/google-noto/NotoSans-Bold.ttf".into(), 0);
|
add("/usr/share/fonts/google-noto/NotoSans-Bold.ttf".into(), 0);
|
||||||
add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf".into(), 0);
|
add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf".into(), 0);
|
||||||
@@ -466,16 +376,18 @@ fn candidates(role: &str) -> Vec<(String, u32)> {
|
|||||||
add(format!("/Library/Fonts/{calibri_mac}"), 0);
|
add(format!("/Library/Fonts/{calibri_mac}"), 0);
|
||||||
add(home_fonts(calibri_mac), 0);
|
add(home_fonts(calibri_mac), 0);
|
||||||
add(format!("{win}\\{calibri_win}"), 0);
|
add(format!("{win}\\{calibri_win}"), 0);
|
||||||
for p in in_dirs(&lin, &[&format!("crosextra/{carlito}"), &format!("carlito/{carlito}")]) {
|
for d in lin {
|
||||||
add(p, 0);
|
add(format!("{d}/crosextra/{carlito}"), 0);
|
||||||
|
add(format!("{d}/carlito/{carlito}"), 0);
|
||||||
}
|
}
|
||||||
// Fedora keeps each family in its own package folder, as for the UI fonts (#54).
|
// Fedora keeps each family in its own package folder, as for the UI fonts (#54).
|
||||||
add(format!("/usr/share/fonts/google-carlito-fonts/{carlito}"), 0);
|
add(format!("/usr/share/fonts/google-carlito-fonts/{carlito}"), 0);
|
||||||
add(home_fonts(carlito), 0);
|
add(home_fonts(carlito), 0);
|
||||||
add(format!("{mac_sup}/{arial_mac}"), 0);
|
add(format!("{mac_sup}/{arial_mac}"), 0);
|
||||||
add(format!("{win}\\{arial_win}"), 0);
|
add(format!("{win}\\{arial_win}"), 0);
|
||||||
for p in in_dirs(&lin, &[&format!("liberation/{lib}"), &format!("dejavu/{dejavu}")]) {
|
for d in lin {
|
||||||
add(p, 0);
|
add(format!("{d}/liberation/{lib}"), 0);
|
||||||
|
add(format!("{d}/dejavu/{dejavu}"), 0);
|
||||||
}
|
}
|
||||||
add(format!("/usr/share/fonts/liberation-sans-fonts/{lib}"), 0);
|
add(format!("/usr/share/fonts/liberation-sans-fonts/{lib}"), 0);
|
||||||
add(format!("/usr/share/fonts/dejavu-sans-fonts/{dejavu}"), 0);
|
add(format!("/usr/share/fonts/dejavu-sans-fonts/{dejavu}"), 0);
|
||||||
@@ -483,8 +395,9 @@ fn candidates(role: &str) -> Vec<(String, u32)> {
|
|||||||
"serif" => {
|
"serif" => {
|
||||||
add(format!("{mac_sup}/Times New Roman.ttf"), 0);
|
add(format!("{mac_sup}/Times New Roman.ttf"), 0);
|
||||||
add(format!("{win}\\times.ttf"), 0);
|
add(format!("{win}\\times.ttf"), 0);
|
||||||
for p in in_dirs(&lin, &["liberation/LiberationSerif-Regular.ttf", "dejavu/DejaVuSerif.ttf"]) {
|
for d in lin {
|
||||||
add(p, 0);
|
add(format!("{d}/liberation/LiberationSerif-Regular.ttf"), 0);
|
||||||
|
add(format!("{d}/dejavu/DejaVuSerif.ttf"), 0);
|
||||||
}
|
}
|
||||||
add("/usr/share/fonts/liberation-serif-fonts/LiberationSerif-Regular.ttf".into(), 0);
|
add("/usr/share/fonts/liberation-serif-fonts/LiberationSerif-Regular.ttf".into(), 0);
|
||||||
add("/usr/share/fonts/dejavu-serif-fonts/DejaVuSerif.ttf".into(), 0);
|
add("/usr/share/fonts/dejavu-serif-fonts/DejaVuSerif.ttf".into(), 0);
|
||||||
@@ -493,14 +406,14 @@ fn candidates(role: &str) -> Vec<(String, u32)> {
|
|||||||
add(format!("{mac_sys}/SFNSMono.ttf"), 0);
|
add(format!("{mac_sys}/SFNSMono.ttf"), 0);
|
||||||
add(format!("{mac_sys}/Menlo.ttc"), 0);
|
add(format!("{mac_sys}/Menlo.ttc"), 0);
|
||||||
add(format!("{win}\\consola.ttf"), 0);
|
add(format!("{win}\\consola.ttf"), 0);
|
||||||
for p in in_dirs(&lin, &["dejavu/DejaVuSansMono.ttf"]) {
|
for d in lin {
|
||||||
add(p, 0);
|
add(format!("{d}/dejavu/DejaVuSansMono.ttf"), 0);
|
||||||
}
|
}
|
||||||
add("/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf".into(), 0);
|
add("/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf".into(), 0);
|
||||||
// Fedora installs DejaVu Sans Mono only on request. Liberation Mono comes with LibreOffice
|
// Fedora installs DejaVu Sans Mono only on request. Liberation Mono comes with LibreOffice
|
||||||
// (in Workstation and KDE by default), Noto Sans Mono (variable, Regular by default) always.
|
// (in Workstation and KDE by default), Noto Sans Mono (variable, Regular by default) always.
|
||||||
for p in in_dirs(&lin, &["liberation/LiberationMono-Regular.ttf"]) {
|
for d in lin {
|
||||||
add(p, 0);
|
add(format!("{d}/liberation/LiberationMono-Regular.ttf"), 0);
|
||||||
}
|
}
|
||||||
add("/usr/share/fonts/liberation-mono-fonts/LiberationMono-Regular.ttf".into(), 0);
|
add("/usr/share/fonts/liberation-mono-fonts/LiberationMono-Regular.ttf".into(), 0);
|
||||||
add("/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf".into(), 0);
|
add("/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf".into(), 0);
|
||||||
@@ -691,24 +604,6 @@ const LINUX_CJK_DIRS: [&str; 22] = [
|
|||||||
"/usr/share/fonts/chromeos/notocjk",
|
"/usr/share/fonts/chromeos/notocjk",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The Windows fonts folder (`%WINDIR%\Fonts`, or the default install location).
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
fn windows_fonts() -> String {
|
|
||||||
std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The Noto Sans CJK faces for one locale (`jp`, `sc`, `kr`): the per-locale OTF, then the
|
|
||||||
/// region-merged TTC at `face`, in every Linux font directory.
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
fn noto_cjk(locale: &str, face: u32) -> Vec<(String, u32)> {
|
|
||||||
let mut v = Vec::new();
|
|
||||||
for d in LINUX_CJK_DIRS {
|
|
||||||
v.push((format!("{d}/NotoSansCJK{locale}-Regular.otf"), 0));
|
|
||||||
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), face));
|
|
||||||
}
|
|
||||||
v
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Japanese Han + Kana faces (Japanese kanji glyph shapes).
|
/// Japanese Han + Kana faces (Japanese kanji glyph shapes).
|
||||||
///
|
///
|
||||||
/// The region-merged `NotoSansCJK-Regular.ttc` holds one face per locale (JP 0, KR 1, SC 2, TC 3,
|
/// The region-merged `NotoSansCJK-Regular.ttc` holds one face per locale (JP 0, KR 1, SC 2, TC 3,
|
||||||
@@ -717,12 +612,15 @@ fn noto_cjk(locale: &str, face: u32) -> Vec<(String, u32)> {
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn cjk_han_japanese() -> Vec<(String, u32)> {
|
fn cjk_han_japanese() -> Vec<(String, u32)> {
|
||||||
let mac = "/System/Library/Fonts";
|
let mac = "/System/Library/Fonts";
|
||||||
let win = windows_fonts();
|
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
|
||||||
let mut v: Vec<(String, u32)> = Vec::new();
|
let mut v: Vec<(String, u32)> = Vec::new();
|
||||||
v.push((format!("{win}\\YuGothR.ttc"), 0)); // Yu Gothic
|
v.push((format!("{win}\\YuGothR.ttc"), 0)); // Yu Gothic
|
||||||
v.push((format!("{win}\\msgothic.ttc"), 0)); // MS Gothic
|
v.push((format!("{win}\\msgothic.ttc"), 0)); // MS Gothic
|
||||||
v.push((format!("{mac}/ヒラギノ角ゴシック W3.ttc"), 0)); // Hiragino Sans
|
v.push((format!("{mac}/ヒラギノ角ゴシック W3.ttc"), 0)); // Hiragino Sans
|
||||||
v.extend(noto_cjk("jp", 0));
|
for d in LINUX_CJK_DIRS {
|
||||||
|
v.push((format!("{d}/NotoSansCJKjp-Regular.otf"), 0));
|
||||||
|
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 0)); // JP face
|
||||||
|
}
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,15 +629,17 @@ fn cjk_han_japanese() -> Vec<(String, u32)> {
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn cjk_han_simplified() -> Vec<(String, u32)> {
|
fn cjk_han_simplified() -> Vec<(String, u32)> {
|
||||||
let mac = "/System/Library/Fonts";
|
let mac = "/System/Library/Fonts";
|
||||||
let win = windows_fonts();
|
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
|
||||||
let mut v: Vec<(String, u32)> = vec![
|
let mut v: Vec<(String, u32)> = Vec::new();
|
||||||
(format!("{win}\\msyh.ttc"), 0), // Microsoft YaHei
|
v.push((format!("{win}\\msyh.ttc"), 0)); // Microsoft YaHei
|
||||||
(format!("{win}\\simsun.ttc"), 0), // SimSun
|
v.push((format!("{win}\\simsun.ttc"), 0)); // SimSun
|
||||||
(format!("{win}\\simhei.ttf"), 0), // SimHei
|
v.push((format!("{win}\\simhei.ttf"), 0)); // SimHei
|
||||||
(format!("{mac}/Hiragino Sans GB.ttc"), 0), // ships on every recent macOS
|
v.push((format!("{mac}/Hiragino Sans GB.ttc"), 0)); // ships on every recent macOS
|
||||||
(format!("{mac}/PingFang.ttc"), 0), // older macOS only; now a downloadable asset
|
v.push((format!("{mac}/PingFang.ttc"), 0)); // older macOS only; now a downloadable asset
|
||||||
];
|
for d in LINUX_CJK_DIRS {
|
||||||
v.extend(noto_cjk("sc", 2));
|
v.push((format!("{d}/NotoSansCJKsc-Regular.otf"), 0));
|
||||||
|
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 2)); // SC face
|
||||||
|
}
|
||||||
for d in LINUX_CJK_DIRS {
|
for d in LINUX_CJK_DIRS {
|
||||||
v.push((format!("{d}/wqy/wqy-microhei.ttc"), 0));
|
v.push((format!("{d}/wqy/wqy-microhei.ttc"), 0));
|
||||||
v.push((format!("{d}/wenquanyi/wqy-microhei.ttc"), 0));
|
v.push((format!("{d}/wenquanyi/wqy-microhei.ttc"), 0));
|
||||||
@@ -753,65 +653,59 @@ fn cjk_han_simplified() -> Vec<(String, u32)> {
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn cjk_hangul() -> Vec<(String, u32)> {
|
fn cjk_hangul() -> Vec<(String, u32)> {
|
||||||
let mac = "/System/Library/Fonts";
|
let mac = "/System/Library/Fonts";
|
||||||
let win = windows_fonts();
|
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
|
||||||
let mut v: Vec<(String, u32)> = Vec::new();
|
let mut v: Vec<(String, u32)> = Vec::new();
|
||||||
v.push((format!("{win}\\malgun.ttf"), 0)); // Malgun Gothic
|
v.push((format!("{win}\\malgun.ttf"), 0)); // Malgun Gothic
|
||||||
v.push((format!("{mac}/AppleSDGothicNeo.ttc"), 0));
|
v.push((format!("{mac}/AppleSDGothicNeo.ttc"), 0));
|
||||||
v.extend(noto_cjk("kr", 1));
|
for d in LINUX_CJK_DIRS {
|
||||||
|
v.push((format!("{d}/NotoSansCJKkr-Regular.otf"), 0));
|
||||||
|
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 1)); // KR face
|
||||||
|
}
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Thai faces. Windows ships Leelawadee UI and Tahoma/Microsoft Sans Serif (both carry Thai);
|
/// Thai faces. Windows ships Leelawadee UI and Tahoma/Microsoft Sans Serif (both carry Thai);
|
||||||
/// Linux font directories searched by the Thai and Hebrew faces, in priority order.
|
/// macOS has Thonburi; Linux usually has Noto Sans Thai or Garuda.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn linux_noto_dirs() -> [&'static str; 5] {
|
fn thai() -> Vec<(String, u32)> {
|
||||||
[
|
let mac_sys = "/System/Library/Fonts";
|
||||||
|
let mac_sup = "/System/Library/Fonts/Supplemental";
|
||||||
|
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
|
||||||
|
let lin = [
|
||||||
"/usr/share/fonts/truetype/noto",
|
"/usr/share/fonts/truetype/noto",
|
||||||
"/usr/share/fonts/opentype/noto",
|
"/usr/share/fonts/opentype/noto",
|
||||||
"/usr/share/fonts/truetype",
|
"/usr/share/fonts/truetype",
|
||||||
"/usr/share/fonts",
|
"/usr/share/fonts",
|
||||||
"/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762)
|
"/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762)
|
||||||
]
|
];
|
||||||
}
|
let mut v: Vec<(String, u32)> = Vec::new();
|
||||||
|
v.push((format!("{win}\\LeelawUI.ttf"), 0)); // Leelawadee UI
|
||||||
/// Faces for one script: Windows files under `WINDIR\Fonts` first, then macOS paths, then the
|
v.push((format!("{win}\\tahoma.ttf"), 0)); // Tahoma
|
||||||
/// Noto/Linux files in every [`linux_noto_dirs`] directory, then the Fedora variable font.
|
v.push((format!("{win}\\micross.ttf"), 0)); // Microsoft Sans Serif
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
v.push((format!("{mac_sup}/Thonburi.ttc"), 0));
|
||||||
fn script_fonts(win: &[&str], mac: &[&str], lin_files: &[&str], variable: &str) -> Vec<(String, u32)> {
|
v.push((format!("{mac_sys}/SukhumvitSet.ttc"), 0));
|
||||||
let w = windows_fonts();
|
for d in lin {
|
||||||
let mut v: Vec<(String, u32)> = win.iter().map(|f| (format!("{w}\\{f}"), 0)).collect();
|
v.push((format!("{d}/NotoSansThai-Regular.ttf"), 0));
|
||||||
v.extend(mac.iter().map(|p| (p.to_string(), 0)));
|
v.push((format!("{d}/tlwg/Garuda.ttf"), 0));
|
||||||
v.extend(in_dirs(&linux_noto_dirs(), lin_files).into_iter().map(|p| (p, 0)));
|
v.push((format!("{d}/garuda/Garuda.ttf"), 0));
|
||||||
v.push((variable.into(), 0));
|
}
|
||||||
|
// Fedora ships the variable Noto Sans Thai; its default instance is Regular.
|
||||||
|
v.push(("/usr/share/fonts/google-noto-vf/NotoSansThai[wght].ttf".into(), 0));
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
/// macOS has Thonburi; Linux usually has Noto Sans Thai or Garuda.
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
fn thai() -> Vec<(String, u32)> {
|
|
||||||
script_fonts(
|
|
||||||
// Leelawadee UI, Tahoma, Microsoft Sans Serif
|
|
||||||
&["LeelawUI.ttf", "tahoma.ttf", "micross.ttf"],
|
|
||||||
&["/System/Library/Fonts/Supplemental/Thonburi.ttc", "/System/Library/Fonts/SukhumvitSet.ttc"],
|
|
||||||
&["NotoSansThai-Regular.ttf", "tlwg/Garuda.ttf", "garuda/Garuda.ttf"],
|
|
||||||
// Fedora ships the variable Noto Sans Thai; its default instance is Regular.
|
|
||||||
"/usr/share/fonts/google-noto-vf/NotoSansThai[wght].ttf",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lao faces. Windows ships Leelawadee UI (and DokChampa on older versions); macOS has Lao Sangam
|
/// Lao faces. Windows ships Leelawadee UI (and DokChampa on older versions); macOS has Lao Sangam
|
||||||
/// MN and Lao MN; Linux usually has Noto Sans Lao or Phetsarath OT.
|
/// MN and Lao MN; Linux usually has Noto Sans Lao or Phetsarath OT.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn lao() -> Vec<(String, u32)> {
|
fn lao() -> Vec<(String, u32)> {
|
||||||
let mac_sup = "/System/Library/Fonts/Supplemental";
|
let mac_sup = "/System/Library/Fonts/Supplemental";
|
||||||
let win = windows_fonts();
|
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
|
||||||
let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"];
|
let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"];
|
||||||
let mut v: Vec<(String, u32)> = vec![
|
let mut v: Vec<(String, u32)> = Vec::new();
|
||||||
(format!("{win}\\LeelawUI.ttf"), 0), // Leelawadee UI
|
v.push((format!("{win}\\LeelawUI.ttf"), 0)); // Leelawadee UI
|
||||||
(format!("{win}\\dokchamp.ttf"), 0), // DokChampa
|
v.push((format!("{win}\\dokchamp.ttf"), 0)); // DokChampa
|
||||||
(format!("{mac_sup}/Lao Sangam MN.ttf"), 0),
|
v.push((format!("{mac_sup}/Lao Sangam MN.ttf"), 0));
|
||||||
(format!("{mac_sup}/Lao MN.ttc"), 0),
|
v.push((format!("{mac_sup}/Lao MN.ttc"), 0));
|
||||||
];
|
|
||||||
for d in lin {
|
for d in lin {
|
||||||
v.push((format!("{d}/NotoSansLao-Regular.ttf"), 0));
|
v.push((format!("{d}/NotoSansLao-Regular.ttf"), 0));
|
||||||
v.push((format!("{d}/lao/Phetsarath_OT.ttf"), 0));
|
v.push((format!("{d}/lao/Phetsarath_OT.ttf"), 0));
|
||||||
@@ -824,20 +718,33 @@ fn lao() -> Vec<(String, u32)> {
|
|||||||
/// Arial Hebrew and David; Linux usually has Noto Sans Hebrew or DejaVu Sans.
|
/// Arial Hebrew and David; Linux usually has Noto Sans Hebrew or DejaVu Sans.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn hebrew() -> Vec<(String, u32)> {
|
fn hebrew() -> Vec<(String, u32)> {
|
||||||
script_fonts(
|
let mac_sys = "/System/Library/Fonts";
|
||||||
// David, FrankRuehl, Tahoma
|
let mac_sup = "/System/Library/Fonts/Supplemental";
|
||||||
&["david.ttf", "frank.ttf", "tahoma.ttf"],
|
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
|
||||||
&[
|
let lin = [
|
||||||
"/System/Library/Fonts/ArialHB.ttc",
|
"/usr/share/fonts/truetype/noto",
|
||||||
"/System/Library/Fonts/SFHebrew.ttf",
|
"/usr/share/fonts/opentype/noto",
|
||||||
"/System/Library/Fonts/Supplemental/Arial Hebrew.ttf",
|
"/usr/share/fonts/truetype",
|
||||||
"/System/Library/Fonts/Supplemental/David.ttf",
|
"/usr/share/fonts",
|
||||||
"/System/Library/Fonts/LucidaGrande.ttc",
|
"/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762)
|
||||||
"/System/Library/Fonts/Lucida Grande.ttc",
|
];
|
||||||
],
|
let mut v: Vec<(String, u32)> = Vec::new();
|
||||||
&["NotoSansHebrew-Regular.ttf", "dejavu/DejaVuSans.ttf", "freefont/FreeSans.ttf"],
|
v.push((format!("{win}\\david.ttf"), 0)); // David
|
||||||
"/usr/share/fonts/google-noto-vf/NotoSansHebrew[wght].ttf",
|
v.push((format!("{win}\\frank.ttf"), 0)); // FrankRuehl
|
||||||
)
|
v.push((format!("{win}\\tahoma.ttf"), 0)); // Tahoma
|
||||||
|
v.push((format!("{mac_sys}/ArialHB.ttc"), 0)); // Arial Hebrew
|
||||||
|
v.push((format!("{mac_sys}/SFHebrew.ttf"), 0));
|
||||||
|
v.push((format!("{mac_sup}/Arial Hebrew.ttf"), 0));
|
||||||
|
v.push((format!("{mac_sup}/David.ttf"), 0));
|
||||||
|
v.push((format!("{mac_sys}/LucidaGrande.ttc"), 0));
|
||||||
|
v.push((format!("{mac_sys}/Lucida Grande.ttc"), 0));
|
||||||
|
for d in lin {
|
||||||
|
v.push((format!("{d}/NotoSansHebrew-Regular.ttf"), 0));
|
||||||
|
v.push((format!("{d}/dejavu/DejaVuSans.ttf"), 0));
|
||||||
|
v.push((format!("{d}/freefont/FreeSans.ttf"), 0));
|
||||||
|
}
|
||||||
|
v.push(("/usr/share/fonts/google-noto-vf/NotoSansHebrew[wght].ttf".into(), 0));
|
||||||
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Symbol faces behind the UI font. Interface fonts such as Noto Sans and Cantarell (and egui's
|
/// Symbol faces behind the UI font. Interface fonts such as Noto Sans and Cantarell (and egui's
|
||||||
@@ -984,104 +891,6 @@ mod font_binding_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod contrast_tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Body text needs 4.5:1 (WCAG AA); glyphs, icons and other graphics need 3:1.
|
|
||||||
const TEXT: f32 = 4.5;
|
|
||||||
const GLYPH: f32 = 3.0;
|
|
||||||
|
|
||||||
/// Every foreground/background pair the chrome draws: ribbon, popups, dialogs, panes, formula
|
|
||||||
/// bar, status bar, plus the colours that sit on the white sheet.
|
|
||||||
fn pairs(t: &Tokens) -> Vec<(String, Color32, Color32, f32)> {
|
|
||||||
let mut v: Vec<(String, Color32, Color32, f32)> = Vec::new();
|
|
||||||
let mut add = |name: &str, fg: Color32, bg: Color32, min: f32| v.push((name.to_string(), fg, bg, min));
|
|
||||||
let surfaces = [
|
|
||||||
("window", t.window),
|
|
||||||
("ribbon", t.ribbon),
|
|
||||||
("menu", t.menu_bg),
|
|
||||||
("input", t.input_bg),
|
|
||||||
("status bar", t.status_bar),
|
|
||||||
("tab bar", t.tab_bar),
|
|
||||||
("active tab", t.tab_active),
|
|
||||||
("hover", t.hover),
|
|
||||||
];
|
|
||||||
for (name, bg) in surfaces {
|
|
||||||
add(&format!("text on {name}"), t.text, bg, TEXT);
|
|
||||||
add(&format!("dim text on {name}"), t.text_dim, bg, TEXT);
|
|
||||||
add(&format!("accent glyph on {name}"), t.accent, bg, GLYPH);
|
|
||||||
add(&format!("danger on {name}"), t.danger, bg, TEXT);
|
|
||||||
}
|
|
||||||
add("text on pressed", t.text, t.pressed, TEXT);
|
|
||||||
add("accent text (File, contextual tabs) on window", t.accent, t.window, TEXT);
|
|
||||||
for (name, bg) in [("ribbon", t.ribbon), ("menu", t.menu_bg), ("hover", t.hover), ("pressed", t.pressed)] {
|
|
||||||
for c in Cat::ALL {
|
|
||||||
add(&format!("{c:?} icon colour on {name}"), t.cat(c), bg, GLYPH);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
add("icon stroke on paper", t.text, t.paper, GLYPH);
|
|
||||||
add("on_accent on accent", t.on_accent, t.accent, TEXT);
|
|
||||||
add("warning glyph", t.on_warning, t.warning, TEXT);
|
|
||||||
add("notice text (warning)", t.text, t.notice_warn_bg, TEXT);
|
|
||||||
add("notice text (info)", t.text, t.notice_info_bg, TEXT);
|
|
||||||
add("header text", t.header_text, t.header_bg, TEXT);
|
|
||||||
add("selected header text", t.header_sel_text, t.header_sel_bg, TEXT);
|
|
||||||
for (i, c) in t.ref_colors.iter().enumerate() {
|
|
||||||
add(&format!("formula bar reference {i}"), *c, t.input_bg, TEXT);
|
|
||||||
}
|
|
||||||
let s = t.syntax;
|
|
||||||
for (name, c) in [("keyword", s.keyword), ("string", s.string), ("number", s.number), ("comment", s.comment), ("builtin", s.builtin)] {
|
|
||||||
add(&format!("code {name} on input"), c, t.input_bg, TEXT);
|
|
||||||
}
|
|
||||||
// On the sheet.
|
|
||||||
add("cell text", t.cell_text, t.grid_bg, TEXT);
|
|
||||||
for (i, c) in t.sheet_ref_colors.iter().enumerate() {
|
|
||||||
add(&format!("sheet reference {i}"), *c, t.grid_bg, TEXT);
|
|
||||||
}
|
|
||||||
add("selection border on sheet", t.sel_border, t.grid_bg, GLYPH);
|
|
||||||
add("sheet control text", t.sheet_control_text, t.sheet_control_bg, TEXT);
|
|
||||||
add("sheet control border on sheet", t.sheet_control_border, t.grid_bg, GLYPH);
|
|
||||||
v
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn token_pairs_meet_wcag_contrast_in_both_themes() {
|
|
||||||
let mut failures = Vec::new();
|
|
||||||
for t in [Tokens::light(), Tokens::dark()] {
|
|
||||||
for (name, fg, bg, min) in pairs(&t) {
|
|
||||||
let ratio = contrast(fg, bg);
|
|
||||||
if ratio < min {
|
|
||||||
failures.push(format!("{} theme: {name} is {ratio:.2}:1, needs {min}:1", if t.dark { "dark" } else { "light" }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(failures.is_empty(), "contrast failures:\n{}", failures.join("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn contrast_matches_wcag_reference_values() {
|
|
||||||
assert!((contrast(Color32::BLACK, Color32::WHITE) - 21.0).abs() < 0.01);
|
|
||||||
assert!((contrast(Color32::WHITE, Color32::WHITE) - 1.0).abs() < 0.001);
|
|
||||||
// #767676 on white is the classic 4.54:1 grey.
|
|
||||||
assert!((contrast(rgb(0x767676), Color32::WHITE) - 4.54).abs() < 0.01);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sheet_tokens_follow_the_sheet_not_the_theme() {
|
|
||||||
let (l, d) = (Tokens::light(), Tokens::dark());
|
|
||||||
assert_eq!(l.grid_bg, d.grid_bg);
|
|
||||||
assert_eq!(l.sheet_ref_colors, d.sheet_ref_colors);
|
|
||||||
assert_eq!(
|
|
||||||
(l.sheet_control_bg, l.sheet_control_border, l.sheet_control_text, l.sheet_track),
|
|
||||||
(d.sheet_control_bg, d.sheet_control_border, d.sheet_control_text, d.sheet_track)
|
|
||||||
);
|
|
||||||
for c in Cat::ALL {
|
|
||||||
assert_ne!(l.cat(c), d.cat(c), "{c:?} has a dark-mode value of its own");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -4,10 +4,9 @@
|
|||||||
use egui::{Align2, Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
use egui::{Align2, Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||||
|
|
||||||
use crate::SheetApp;
|
use crate::SheetApp;
|
||||||
pub use crate::code_editor::{Lang, code_editor};
|
|
||||||
use crate::i18n;
|
use crate::i18n;
|
||||||
use crate::icons::{self, Icon};
|
use crate::icons::{self, Icon};
|
||||||
use crate::theme::{self, Cat, Tokens};
|
use crate::theme::{self, Tokens};
|
||||||
|
|
||||||
/// Translates a label or tooltip for the language of the frame being drawn (English if untranslated).
|
/// Translates a label or tooltip for the language of the frame being drawn (English if untranslated).
|
||||||
/// Ribbon chrome all flows through these widgets, so translating here localises it in one place.
|
/// Ribbon chrome all flows through these widgets, so translating here localises it in one place.
|
||||||
@@ -80,8 +79,13 @@ pub fn toggle_button(ui: &mut Ui, icon: Icon, on: bool, tip: &str) -> Response {
|
|||||||
/// Large ribbon button: 32px icon over a one- or two-line label.
|
/// Large ribbon button: 32px icon over a one- or two-line label.
|
||||||
pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: bool) -> Response {
|
pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: bool) -> Response {
|
||||||
let t = Tokens::get(ui.ctx());
|
let t = Tokens::get(ui.ctx());
|
||||||
|
let font = theme::ui_font(11.5);
|
||||||
let label = tr(ui, label);
|
let label = tr(ui, label);
|
||||||
let (lines, w) = big_label(ui, label, dropdown);
|
let lines: Vec<_> = label.split('\n').map(|line| ui.painter().layout_no_wrap(line.to_string(), font.clone(), t.text)).collect();
|
||||||
|
let arrow_size = 10.0;
|
||||||
|
let arrow_space = if dropdown { 4.0 + arrow_size } else { 0.0 };
|
||||||
|
let text_w = lines.iter().enumerate().map(|(i, line)| line.size().x + if i + 1 == lines.len() { arrow_space } else { 0.0 }).fold(0.0, f32::max);
|
||||||
|
let w = (text_w + 12.0).max(44.0);
|
||||||
let (rect, resp) = ui.allocate_exact_size(vec2(w, 72.0), Sense::click());
|
let (rect, resp) = ui.allocate_exact_size(vec2(w, 72.0), Sense::click());
|
||||||
if resp.is_pointer_button_down_on() {
|
if resp.is_pointer_button_down_on() {
|
||||||
ui.painter().rect_filled(rect, 5.0, t.pressed);
|
ui.painter().rect_filled(rect, 5.0, t.pressed);
|
||||||
@@ -89,59 +93,6 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: boo
|
|||||||
ui.painter().rect_filled(rect, 5.0, t.hover);
|
ui.painter().rect_filled(rect, 5.0, t.hover);
|
||||||
}
|
}
|
||||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.center().x, rect.top() + 21.0), vec2(30.0, 30.0)), icon, t.text);
|
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.center().x, rect.top() + 21.0), vec2(30.0, 30.0)), icon, t.text);
|
||||||
paint_big_label(ui, rect, &lines, dropdown);
|
|
||||||
resp.on_hover_text(tip_text(ui, tip))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Large split button (Formulas › AutoSum): the icon runs the default action, the label and its
|
|
||||||
/// arrow open the menu. Returns the (main, arrow) responses; their accessible names are the label
|
|
||||||
/// and "<label> options".
|
|
||||||
pub fn big_split_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str) -> (Response, Response) {
|
|
||||||
let t = Tokens::get(ui.ctx());
|
|
||||||
let label = tr(ui, label);
|
|
||||||
let (lines, w) = big_label(ui, label, true);
|
|
||||||
let (rect, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover());
|
|
||||||
let split = rect.top() + 38.0;
|
|
||||||
let main_rect = Rect::from_min_max(rect.min, pos2(rect.right(), split));
|
|
||||||
let arrow_rect = Rect::from_min_max(pos2(rect.left(), split), rect.max);
|
|
||||||
let main = ui.interact(main_rect, ui.id().with((label, "big-split-main")), Sense::click());
|
|
||||||
let arrow = ui.interact(arrow_rect, ui.id().with((label, "big-split-arrow")), Sense::click());
|
|
||||||
if main.hovered() || arrow.hovered() {
|
|
||||||
ui.painter().rect_filled(rect, 5.0, t.hover);
|
|
||||||
ui.painter().line_segment([pos2(rect.left() + 4.0, split), pos2(rect.right() - 4.0, split)], Stroke::new(1.0, t.separator));
|
|
||||||
}
|
|
||||||
for (part, r) in [(&main, main_rect), (&arrow, arrow_rect)] {
|
|
||||||
if part.is_pointer_button_down_on() {
|
|
||||||
ui.painter().rect_filled(r, 5.0, t.pressed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.center().x, rect.top() + 21.0), vec2(30.0, 30.0)), icon, t.text);
|
|
||||||
paint_big_label(ui, rect, &lines, true);
|
|
||||||
let name = label.replace('\n', " ");
|
|
||||||
let enabled = ui.is_enabled();
|
|
||||||
main.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &name));
|
|
||||||
arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, format!("{name} options")));
|
|
||||||
let tip = tip_text(ui, tip);
|
|
||||||
(main.on_hover_text(tip.clone()), arrow.on_hover_text(tip))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Size of the dropdown arrow beside a big button's last label line.
|
|
||||||
const BIG_ARROW: f32 = 10.0;
|
|
||||||
|
|
||||||
/// A big button's label lines, laid out, and the width the button needs for them.
|
|
||||||
fn big_label(ui: &Ui, label: &str, dropdown: bool) -> (Vec<std::sync::Arc<egui::Galley>>, f32) {
|
|
||||||
let t = Tokens::get(ui.ctx());
|
|
||||||
let font = theme::ui_font(11.5);
|
|
||||||
let lines: Vec<_> = label.split('\n').map(|line| ui.painter().layout_no_wrap(line.to_string(), font.clone(), t.text)).collect();
|
|
||||||
let arrow_space = if dropdown { 4.0 + BIG_ARROW } else { 0.0 };
|
|
||||||
let text_w = lines.iter().enumerate().map(|(i, line)| line.size().x + if i + 1 == lines.len() { arrow_space } else { 0.0 }).fold(0.0, f32::max);
|
|
||||||
(lines, (text_w + 12.0).max(44.0))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Paints a big button's label lines in `rect`, with the dropdown arrow beside the last one.
|
|
||||||
fn paint_big_label(ui: &Ui, rect: Rect, lines: &[std::sync::Arc<egui::Galley>], dropdown: bool) {
|
|
||||||
let t = Tokens::get(ui.ctx());
|
|
||||||
let arrow_space = if dropdown { 4.0 + BIG_ARROW } else { 0.0 };
|
|
||||||
for (i, line) in lines.iter().enumerate() {
|
for (i, line) in lines.iter().enumerate() {
|
||||||
let has_arrow = dropdown && i + 1 == lines.len();
|
let has_arrow = dropdown && i + 1 == lines.len();
|
||||||
let line_w = line.size().x + if has_arrow { arrow_space } else { 0.0 };
|
let line_w = line.size().x + if has_arrow { arrow_space } else { 0.0 };
|
||||||
@@ -151,12 +102,13 @@ fn paint_big_label(ui: &Ui, rect: Rect, lines: &[std::sync::Arc<egui::Galley>],
|
|||||||
if has_arrow {
|
if has_arrow {
|
||||||
icons::paint(
|
icons::paint(
|
||||||
ui.painter(),
|
ui.painter(),
|
||||||
Rect::from_center_size(pos2(left + line_w - BIG_ARROW / 2.0, y + 1.0), vec2(BIG_ARROW, BIG_ARROW)),
|
Rect::from_center_size(pos2(left + line_w - arrow_size / 2.0, y + 1.0), vec2(arrow_size, arrow_size)),
|
||||||
Icon::Chevron,
|
Icon::Chevron,
|
||||||
t.text_dim,
|
t.text_dim,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
resp.on_hover_text(tip_text(ui, tip))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Small ribbon button: 16px icon with optional label to the right.
|
/// Small ribbon button: 16px icon with optional label to the right.
|
||||||
@@ -208,34 +160,6 @@ pub fn split_button(ui: &mut Ui, icon: Icon, accent: Option<Color32>, tip: &str)
|
|||||||
(m.on_hover_text(tip_text(ui, tip)).clicked(), a)
|
(m.on_hover_text(tip_text(ui, tip)).clicked(), a)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A small ribbon split button with a label (Error Checking): the icon and label run the default
|
|
||||||
/// action, the arrow at the right opens a menu. Returns (main response, arrow response).
|
|
||||||
pub fn small_split_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str) -> (Response, Response) {
|
|
||||||
let t = Tokens::get(ui.ctx());
|
|
||||||
let font = theme::ui_font(12.5);
|
|
||||||
let label = tr(ui, label);
|
|
||||||
let tw = ui.painter().layout_no_wrap(label.to_string(), font.clone(), t.text).size().x + 6.0;
|
|
||||||
let (rect, _) = ui.allocate_exact_size(vec2(24.0 + tw + 14.0, 23.0), Sense::hover());
|
|
||||||
let main = Rect::from_min_size(rect.min, vec2(24.0 + tw, rect.height()));
|
|
||||||
let arrow = Rect::from_min_max(pos2(main.right(), rect.top()), rect.max);
|
|
||||||
let m = ui.interact(main, ui.id().with((tip, "main")), Sense::click());
|
|
||||||
let a = ui.interact(arrow, ui.id().with((tip, "arrow")), Sense::click());
|
|
||||||
if m.hovered() || a.hovered() {
|
|
||||||
ui.painter().rect_filled(rect, 4.0, t.hover);
|
|
||||||
for (part, r) in [(&m, main), (&a, arrow)] {
|
|
||||||
if part.is_pointer_button_down_on() {
|
|
||||||
ui.painter().rect_filled(r, 4.0, t.pressed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ui.painter().line_segment([pos2(main.right(), rect.top() + 3.0), pos2(main.right(), rect.bottom() - 3.0)], Stroke::new(1.0, t.separator));
|
|
||||||
}
|
|
||||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 12.0, rect.center().y), vec2(16.0, 16.0)), icon, t.text);
|
|
||||||
ui.painter().text(pos2(rect.left() + 24.0, rect.center().y), Align2::LEFT_CENTER, label, font, t.text);
|
|
||||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(arrow.center().x, rect.center().y + 1.0), vec2(10.0, 10.0)), Icon::Chevron, t.text_dim);
|
|
||||||
let tip = tip_text(ui, tip);
|
|
||||||
(m.on_hover_text(tip.clone()), a.on_hover_text(tip))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A one-line text field for a value stored in the workbook, which callers reload every frame. The text being typed is
|
/// A one-line text field for a value stored in the workbook, which callers reload every frame. The text being typed is
|
||||||
/// kept in egui memory until the field loses focus, then returned to commit (`None` when Escape cancels the edit).
|
/// kept in egui memory until the field loses focus, then returned to commit (`None` when Escape cancels the edit).
|
||||||
pub fn committed_text(ui: &mut Ui, id: egui::Id, current: &str, width: Option<f32>) -> Option<String> {
|
pub fn committed_text(ui: &mut Ui, id: egui::Id, current: &str, width: Option<f32>) -> Option<String> {
|
||||||
@@ -316,7 +240,6 @@ pub fn color_palette_fixed(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &s
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors: bool) -> Option<String> {
|
fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors: bool) -> Option<String> {
|
||||||
let t = Tokens::get(ui.ctx());
|
|
||||||
let mut picked = None;
|
let mut picked = None;
|
||||||
const ROWS: usize = 7; // 6 tint rows + 1 standard row
|
const ROWS: usize = 7; // 6 tint rows + 1 standard row
|
||||||
const COLS: usize = 10;
|
const COLS: usize = 10;
|
||||||
@@ -410,9 +333,9 @@ fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors:
|
|||||||
let col32 = Color32::from_rgb(c[0], c[1], c[2]);
|
let col32 = Color32::from_rgb(c[0], c[1], c[2]);
|
||||||
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::click());
|
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::click());
|
||||||
ui.painter().rect_filled(r, 1.0, col32);
|
ui.painter().rect_filled(r, 1.0, col32);
|
||||||
ui.painter().rect_stroke(r, 1.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
|
ui.painter().rect_stroke(r, 1.0, Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside);
|
||||||
if resp.hovered() || cur == Some((row, col)) {
|
if resp.hovered() || cur == Some((row, col)) {
|
||||||
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, t.cat(Cat::Orange)), StrokeKind::Outside);
|
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
|
||||||
}
|
}
|
||||||
if resp.clicked() {
|
if resp.clicked() {
|
||||||
picked = Some(theme_hex(base, *tint));
|
picked = Some(theme_hex(base, *tint));
|
||||||
@@ -432,7 +355,7 @@ fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors:
|
|||||||
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::click());
|
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::click());
|
||||||
ui.painter().rect_filled(r, 1.0, c);
|
ui.painter().rect_filled(r, 1.0, c);
|
||||||
if resp.hovered() || cur == Some((ROWS - 1, col)) {
|
if resp.hovered() || cur == Some((ROWS - 1, col)) {
|
||||||
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, t.cat(Cat::Orange)), StrokeKind::Outside);
|
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
|
||||||
}
|
}
|
||||||
if resp.clicked() {
|
if resp.clicked() {
|
||||||
picked = Some(hex.to_string());
|
picked = Some(hex.to_string());
|
||||||
@@ -519,9 +442,8 @@ pub fn message_box(app: &mut SheetApp, ctx: &egui::Context) {
|
|||||||
ui.set_width(380.0);
|
ui.set_width(380.0);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let (r, _) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::hover());
|
let (r, _) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::hover());
|
||||||
let t = Tokens::get(ui.ctx());
|
ui.painter().circle_filled(r.center(), 16.0, Color32::from_rgb(0xF2, 0xC3, 0x2E));
|
||||||
ui.painter().circle_filled(r.center(), 16.0, t.warning);
|
ui.painter().text(r.center(), Align2::CENTER_CENTER, "!", theme::ui_bold(20.0), Color32::WHITE);
|
||||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, "!", theme::ui_bold(20.0), t.on_warning);
|
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.label(egui::RichText::new(title).strong());
|
ui.label(egui::RichText::new(title).strong());
|
||||||
ui.add(egui::Label::new(msg).wrap());
|
ui.add(egui::Label::new(msg).wrap());
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
//! Formulas › Error Checking is a split button, as in Excel: the button opens the Error Checking
|
|
||||||
//! dialog, and its arrow opens a menu whose Circular References submenu lists a cell on each
|
|
||||||
//! circular reference and selects the one picked, on its own sheet.
|
|
||||||
|
|
||||||
use egui::{Event, Shape, pos2, vec2};
|
|
||||||
use egui_kittest::kittest::{NodeT as _, Queryable as _};
|
|
||||||
use gridcraft_engine::Session;
|
|
||||||
use gridcraft_ui_egui::SheetApp;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
|
||||||
|
|
||||||
/// The submenu item, with the arrow egui puts after a submenu's label.
|
|
||||||
const CIRCULAR: &str = "Circular References ⏵";
|
|
||||||
|
|
||||||
fn harness(loops: bool) -> Harness {
|
|
||||||
let mut s = Session::new();
|
|
||||||
s.new_workbook();
|
|
||||||
s.execute("home.insertSheet", json!({"name": "Q1 Data"})).unwrap();
|
|
||||||
if loops {
|
|
||||||
s.execute("cell.set", json!({"cell": "B3", "input": "=B4"})).unwrap();
|
|
||||||
s.execute("cell.set", json!({"cell": "B4", "input": "=B3"})).unwrap();
|
|
||||||
}
|
|
||||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
|
||||||
if loops {
|
|
||||||
s.execute("cell.set", json!({"cell": "C2", "input": "=C2+1"})).unwrap();
|
|
||||||
}
|
|
||||||
s.execute("selection.set", json!({"range": "H20"})).unwrap();
|
|
||||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 800.0)).build_ui_state(
|
|
||||||
|ui, app: &mut SheetApp| {
|
|
||||||
app.logic(ui.ctx());
|
|
||||||
app.ui(ui);
|
|
||||||
},
|
|
||||||
SheetApp::new(s, Default::default()),
|
|
||||||
);
|
|
||||||
h.run_steps(4);
|
|
||||||
h.state_mut().ui.ribbon_tab = "Formulas".into();
|
|
||||||
h.run_steps(4);
|
|
||||||
h
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The painted "Error Checking" ribbon label: its left edge, right edge and vertical center.
|
|
||||||
fn label(h: &Harness) -> (f32, f32, f32) {
|
|
||||||
h.output()
|
|
||||||
.shapes
|
|
||||||
.iter()
|
|
||||||
.find_map(|s| match &s.shape {
|
|
||||||
Shape::Text(t) if t.galley.job.text == "Error Checking" => {
|
|
||||||
Some((t.pos.x, t.pos.x + t.galley.size().x, t.pos.y + t.galley.size().y / 2.0))
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.expect("the Formulas tab shows Error Checking")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn click(h: &mut Harness, at: egui::Pos2) {
|
|
||||||
h.input_mut().events.push(Event::PointerMoved(at));
|
|
||||||
h.run_steps(2);
|
|
||||||
for pressed in [true, false] {
|
|
||||||
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
|
|
||||||
h.run_steps(2);
|
|
||||||
}
|
|
||||||
h.run_steps(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_menu(h: &mut Harness) {
|
|
||||||
let (_, right, y) = label(h);
|
|
||||||
// The arrow sits right of the label, past the 6 px gap after the text.
|
|
||||||
click(h, pos2(right + 13.0, y));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hover(h: &mut Harness, label: &str) {
|
|
||||||
let at = h.get_by_label(label).rect().center();
|
|
||||||
h.input_mut().events.push(Event::PointerMoved(at));
|
|
||||||
h.run_steps(6);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn error_checking_button_opens_the_dialog_and_its_arrow_opens_the_menu() {
|
|
||||||
let mut h = harness(false);
|
|
||||||
let (left, _, y) = label(&h);
|
|
||||||
click(&mut h, pos2(left + 4.0, y));
|
|
||||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("errorChecking"), "the button opens Error Checking");
|
|
||||||
h.state_mut().dialog = None;
|
|
||||||
h.run_steps(2);
|
|
||||||
open_menu(&mut h);
|
|
||||||
assert!(h.state().dialog.is_none(), "the arrow opens a menu, not the dialog");
|
|
||||||
h.get_by_label("Error Checking…").click();
|
|
||||||
h.run_steps(4);
|
|
||||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("errorChecking"), "the menu's first item opens Error Checking");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn circular_references_is_unavailable_without_a_circular_reference() {
|
|
||||||
let mut h = harness(false);
|
|
||||||
open_menu(&mut h);
|
|
||||||
assert!(h.get_by_label(CIRCULAR).accesskit_node().is_disabled());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn circular_references_lists_the_loops_and_selects_the_cell_picked() {
|
|
||||||
let mut h = harness(true);
|
|
||||||
open_menu(&mut h);
|
|
||||||
assert!(!h.get_by_label(CIRCULAR).accesskit_node().is_disabled());
|
|
||||||
hover(&mut h, CIRCULAR);
|
|
||||||
assert!(h.query_by_label("Sheet1!C2").is_some(), "the loop on the active sheet is listed");
|
|
||||||
// A sheet name with a space is quoted, as in a formula.
|
|
||||||
let other = ["'Q1 Data'!B3", "'Q1 Data'!B4"].into_iter().find(|l| h.query_by_label(l).is_some()).expect("the loop on Q1 Data is listed");
|
|
||||||
h.get_by_label(other).click();
|
|
||||||
h.run_steps(4);
|
|
||||||
let app = h.state();
|
|
||||||
let d = app.session.active().unwrap();
|
|
||||||
assert_eq!(d.wb.active_sheet, 1, "picking a cell on Q1 Data goes to Q1 Data");
|
|
||||||
assert_eq!(format!("'Q1 Data'!{}", d.selection.active.a1()), other);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn circular_references_scrolls_to_the_cell_picked() {
|
|
||||||
let mut h = harness(false);
|
|
||||||
h.state_mut().run("cell.set", json!({"cell": "C500", "input": "=C500+1"})).unwrap();
|
|
||||||
h.state_mut().run("selection.set", json!({"range": "H20"})).unwrap();
|
|
||||||
h.run_steps(4);
|
|
||||||
assert_eq!(h.state().view().scroll.y, 0.0, "setup: the view is at the top of the sheet");
|
|
||||||
open_menu(&mut h);
|
|
||||||
hover(&mut h, CIRCULAR);
|
|
||||||
h.get_by_label("Sheet1!C500").click();
|
|
||||||
h.run_steps(4);
|
|
||||||
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "C500");
|
|
||||||
assert!(h.state().view().scroll.y > 0.0, "the grid scrolls to the cell picked");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn circular_references_scrolls_a_list_taller_than_the_window() {
|
|
||||||
let mut h = harness(false);
|
|
||||||
for row in 1..=100 {
|
|
||||||
h.state_mut().run("cell.set", json!({"cell": format!("A{row}"), "input": format!("=A{row}+1")})).unwrap();
|
|
||||||
}
|
|
||||||
h.state_mut().run("selection.set", json!({"range": "H20"})).unwrap();
|
|
||||||
h.run_steps(4);
|
|
||||||
open_menu(&mut h);
|
|
||||||
hover(&mut h, CIRCULAR);
|
|
||||||
let window = h.ctx.content_rect();
|
|
||||||
assert!(h.get_by_label("Sheet1!A100").rect().bottom() > window.bottom(), "setup: the last cell starts below the window");
|
|
||||||
hover(&mut h, "Sheet1!A1");
|
|
||||||
for _ in 0..20 {
|
|
||||||
h.input_mut().events.push(Event::MouseWheel {
|
|
||||||
unit: egui::MouseWheelUnit::Line,
|
|
||||||
delta: vec2(0.0, -10.0),
|
|
||||||
phase: egui::TouchPhase::Move,
|
|
||||||
modifiers: egui::Modifiers::NONE,
|
|
||||||
});
|
|
||||||
h.run_steps(4);
|
|
||||||
}
|
|
||||||
let last = h.get_by_label("Sheet1!A100").rect();
|
|
||||||
assert!(last.bottom() <= window.bottom(), "the wheel brings the last cell into the window: {last:?} in {window:?}");
|
|
||||||
click(&mut h, last.center());
|
|
||||||
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A100");
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
//! Dragging the fill handle with the right button fills nothing on release: it opens Excel's fill
|
//! Dragging the fill handle with the right button fills nothing on release: it opens Excel's fill
|
||||||
//! menu (Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…, and from
|
//! menu (Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…).
|
||||||
//! a date Fill Days, Fill Weekdays, Fill Months and Fill Years).
|
|
||||||
|
|
||||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2};
|
use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2};
|
||||||
use egui_kittest::kittest::Queryable;
|
use egui_kittest::kittest::Queryable;
|
||||||
@@ -204,34 +203,6 @@ fn series_opens_the_series_dialog_on_the_dragged_cells_in_their_direction() {
|
|||||||
assert_eq!(values(&h, &["C1", "D1", "E1"]), [n(2.0), n(3.0), n(4.0)]);
|
assert_eq!(values(&h, &["C1", "D1", "E1"]), [n(2.0), n(3.0), n(4.0)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_date_adds_fill_days_weekdays_months_and_years() {
|
|
||||||
let mut h = harness(|s| {
|
|
||||||
// Friday 1/30/2026 is serial 46052.
|
|
||||||
s.execute("cell.set", json!({"cell": "B1", "input": "1/30/2026"})).unwrap();
|
|
||||||
s.execute("selection.set", json!({"cell": "B1"})).unwrap();
|
|
||||||
});
|
|
||||||
drag_handle(&mut h, "B1", "B3", PointerButton::Secondary);
|
|
||||||
for label in ["Fill Days", "Fill Months", "Fill Years"] {
|
|
||||||
assert!(h.query_by_label(label).is_some(), "{label}");
|
|
||||||
}
|
|
||||||
h.get_by_label("Fill Weekdays").click();
|
|
||||||
h.run_steps(3);
|
|
||||||
|
|
||||||
assert_eq!(values(&h, &["B2", "B3"]), [n(46055.0), n(46056.0)], "Monday 2/2 and Tuesday 2/3");
|
|
||||||
assert!(h.state().grid.fill_menu.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_number_gets_no_date_fills() {
|
|
||||||
let mut h = harness(one_two);
|
|
||||||
drag_handle(&mut h, "B2", "B6", PointerButton::Secondary);
|
|
||||||
assert!(h.query_by_label("Fill Series").is_some());
|
|
||||||
for label in ["Fill Days", "Fill Weekdays", "Fill Months", "Fill Years"] {
|
|
||||||
assert!(h.query_by_label(label).is_none(), "{label}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn escape_mid_right_drag_cancels_it() {
|
fn escape_mid_right_drag_cancels_it() {
|
||||||
let mut h = harness(|s| {
|
let mut h = harness(|s| {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! After a left-drag of the fill handle, the Auto Fill Options button redoes the fill as Copy Cells,
|
//! After a left-drag of the fill handle, the Auto Fill Options button redoes the fill as Copy Cells,
|
||||||
//! Fill Series, Fill Formatting Only or Fill Without Formatting (#1403), and from a date as Fill
|
//! Fill Series, Fill Formatting Only or Fill Without Formatting (#1403).
|
||||||
//! Days, Fill Weekdays, Fill Months or Fill Years.
|
|
||||||
|
|
||||||
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
|
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
|
||||||
use egui_kittest::kittest::Queryable;
|
use egui_kittest::kittest::Queryable;
|
||||||
@@ -140,33 +139,6 @@ fn a_lone_number_copies_on_drag_and_counts_up_with_fill_series() {
|
|||||||
assert_eq!(values(&h, &["B2", "B3", "B4"]), [n(6.0), n(7.0), n(8.0)]);
|
assert_eq!(values(&h, &["B2", "B3", "B4"]), [n(6.0), n(7.0), n(8.0)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fill_months_redoes_a_date_drag_by_months() {
|
|
||||||
let mut h = harness(|s| {
|
|
||||||
// 1/31/2026 is serial 46053.
|
|
||||||
s.execute("cell.set", json!({"cell": "B1", "input": "1/31/2026"})).unwrap();
|
|
||||||
s.execute("selection.set", json!({"cell": "B1"})).unwrap();
|
|
||||||
});
|
|
||||||
drag_handle(&mut h, "B1", "B3", PointerButton::Primary);
|
|
||||||
assert_eq!(values(&h, &["B2", "B3"]), [n(46054.0), n(46055.0)], "a plain drag steps by days");
|
|
||||||
h.get_by_label("Auto Fill Options").click();
|
|
||||||
h.run_steps(3);
|
|
||||||
h.get_by_label("Fill Months").click();
|
|
||||||
h.run_steps(3);
|
|
||||||
|
|
||||||
assert_eq!(values(&h, &["B2", "B3"]), [n(46081.0), n(46112.0)], "Feb 28 and Mar 31");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_number_drag_offers_no_date_fills() {
|
|
||||||
let mut h = harness(one_two);
|
|
||||||
drag_handle(&mut h, "B2", "B6", PointerButton::Primary);
|
|
||||||
h.get_by_label("Auto Fill Options").click();
|
|
||||||
h.run_steps(3);
|
|
||||||
assert!(h.query_by_label("Copy Cells").is_some());
|
|
||||||
assert!(h.query_by_label("Fill Days").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn formatted(s: &mut Session) {
|
fn formatted(s: &mut Session) {
|
||||||
s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [null], [9]]})).unwrap();
|
s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [null], [9]]})).unwrap();
|
||||||
s.execute("home.bold", json!({"range": "B1:B2", "on": true})).unwrap();
|
s.execute("home.bold", json!({"range": "B1:B2", "on": true})).unwrap();
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
//! The auto-growing formula bar, the 5-line in-cell editor and the code editor widget.
|
|
||||||
|
|
||||||
use egui::{Event, Key, Modifiers, Rect, vec2};
|
|
||||||
use gridcraft_engine::Session;
|
|
||||||
use gridcraft_engine::core::{CellRef, Value};
|
|
||||||
use gridcraft_ui_egui::formula_bar::{BarLayout, MAX_LINES, one_line};
|
|
||||||
use gridcraft_ui_egui::widgets::{Lang, code_editor};
|
|
||||||
use gridcraft_ui_egui::{SheetApp, theme};
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
|
||||||
|
|
||||||
fn harness(session: Session) -> Harness {
|
|
||||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
|
||||||
|ui, app: &mut SheetApp| {
|
|
||||||
app.logic(ui.ctx());
|
|
||||||
app.ui(ui);
|
|
||||||
},
|
|
||||||
SheetApp::new(session, Default::default()),
|
|
||||||
);
|
|
||||||
settle(&mut h);
|
|
||||||
h
|
|
||||||
}
|
|
||||||
|
|
||||||
fn blank() -> Session {
|
|
||||||
let mut s = Session::new();
|
|
||||||
s.new_workbook();
|
|
||||||
s
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Long enough for the 120 ms height ease to finish.
|
|
||||||
fn settle(h: &mut Harness) {
|
|
||||||
h.run_steps(16);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn key(key: Key, modifiers: Modifiers) -> [Event; 2] {
|
|
||||||
[
|
|
||||||
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
|
|
||||||
Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn type_text(h: &mut Harness, text: &str) {
|
|
||||||
h.input_mut().events.push(Event::Text(text.into()));
|
|
||||||
h.run_steps(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn alt_enter(h: &mut Harness) {
|
|
||||||
h.input_mut().events.extend(key(Key::Enter, Modifiers::ALT));
|
|
||||||
h.run_steps(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn layout(h: &Harness) -> BarLayout {
|
|
||||||
BarLayout::get(&h.ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row_height(h: &Harness) -> f32 {
|
|
||||||
h.ctx.fonts_mut(|f| f.row_height(&theme::ui_font(13.0)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn same(a: Rect, b: Rect) -> bool {
|
|
||||||
(a.min - b.min).length() < 0.5 && (a.max - b.max).length() < 0.5
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bar_grows_to_five_lines_then_scrolls_with_name_box_pinned() {
|
|
||||||
let mut h = harness(blank());
|
|
||||||
// The one-line height is 30 px unless the font's rows need more (Windows' do).
|
|
||||||
let rh = row_height(&h);
|
|
||||||
let base = one_line(rh);
|
|
||||||
let one = layout(&h);
|
|
||||||
assert!((one.height - base).abs() < 0.5, "one line is {base} px, got {}", one.height);
|
|
||||||
assert_eq!(one.lines, 1);
|
|
||||||
h.state_mut().begin_edit(None, true);
|
|
||||||
h.run_steps(2);
|
|
||||||
type_text(&mut h, "line 1");
|
|
||||||
for n in 2..=8 {
|
|
||||||
alt_enter(&mut h);
|
|
||||||
type_text(&mut h, &format!("line {n}"));
|
|
||||||
settle(&mut h);
|
|
||||||
let l = layout(&h);
|
|
||||||
assert_eq!(l.lines, n, "the editor holds {n} lines");
|
|
||||||
let expect = base + (n.min(MAX_LINES) - 1) as f32 * rh;
|
|
||||||
assert!((l.height - expect).abs() < 0.5, "{n} lines: height {} expected {expect}", l.height);
|
|
||||||
assert!(same(l.name_box, one.name_box), "the Name Box stays on the first line ({n} lines)");
|
|
||||||
assert!(same(l.buttons, one.buttons), "the buttons stay on the first line ({n} lines)");
|
|
||||||
let caret = l.caret.expect("a caret while editing");
|
|
||||||
assert!(l.field.contains_rect(caret.shrink(0.5)), "the caret stays visible at {n} lines: {caret:?} in {:?}", l.field);
|
|
||||||
}
|
|
||||||
assert_eq!(layout(&h).visible_lines, MAX_LINES);
|
|
||||||
// Enter commits the multi-line text; viewing the cell keeps the bar at five lines.
|
|
||||||
h.input_mut().events.extend(key(Key::Enter, Modifiers::NONE));
|
|
||||||
settle(&mut h);
|
|
||||||
let text = (1..=8).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
|
|
||||||
let d = h.state().session.active().unwrap();
|
|
||||||
assert_eq!(d.wb.active().unwrap().value(CellRef::parse("A1").unwrap()), Value::from(text.as_str()));
|
|
||||||
h.state_mut().session.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
|
||||||
settle(&mut h);
|
|
||||||
let viewing = layout(&h);
|
|
||||||
assert_eq!(viewing.lines, 8);
|
|
||||||
assert!((viewing.height - (base + 4.0 * rh)).abs() < 0.5);
|
|
||||||
// An empty cell shrinks it back to one line.
|
|
||||||
h.state_mut().session.execute("selection.set", json!({"cell": "B1"})).unwrap();
|
|
||||||
settle(&mut h);
|
|
||||||
assert!((layout(&h).height - base).abs() < 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enter_that_closes_a_message_box_adds_no_line_to_the_entry() {
|
|
||||||
// A message box over the editor (a data validation alert) takes Enter to close. The bar's
|
|
||||||
// field is multi-line, and a field of its own would add a line on that Enter; the editor
|
|
||||||
// handles Enter itself, so the field does nothing with it.
|
|
||||||
let mut h = harness(blank());
|
|
||||||
h.state_mut().begin_edit(None, true);
|
|
||||||
h.run_steps(2);
|
|
||||||
type_text(&mut h, "abc");
|
|
||||||
h.state_mut().message = Some(("Stop".into(), "Whole numbers only.".into()));
|
|
||||||
h.run_steps(2);
|
|
||||||
h.input_mut().events.extend(key(Key::Enter, Modifiers::NONE));
|
|
||||||
h.run_steps(2);
|
|
||||||
assert!(h.state().message.is_none(), "Enter closed the message box");
|
|
||||||
assert_eq!(h.state().editor.as_ref().map(|e| e.text.as_str()), Some("abc"), "and added nothing to the entry");
|
|
||||||
assert_eq!(layout(&h).lines, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn chevron_pins_the_bar_at_five_lines() {
|
|
||||||
let mut h = harness(blank());
|
|
||||||
h.state_mut().ui.formula_bar_expanded = true;
|
|
||||||
settle(&mut h);
|
|
||||||
let rh = row_height(&h);
|
|
||||||
let base = one_line(rh);
|
|
||||||
assert!((layout(&h).height - (base + 4.0 * rh)).abs() < 0.5);
|
|
||||||
h.state_mut().ui.formula_bar_expanded = false;
|
|
||||||
settle(&mut h);
|
|
||||||
assert!((layout(&h).height - base).abs() < 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn height_eases_instead_of_jumping() {
|
|
||||||
let mut h = harness(blank());
|
|
||||||
h.state_mut().ui.formula_bar_expanded = true;
|
|
||||||
h.run_steps(2);
|
|
||||||
let mid = layout(&h).height;
|
|
||||||
let rh = row_height(&h);
|
|
||||||
let base = one_line(rh);
|
|
||||||
assert!(mid > base + 0.5 && mid < base + 4.0 * rh - 0.5, "mid-ease height {mid}");
|
|
||||||
settle(&mut h);
|
|
||||||
assert!((layout(&h).height - (base + 4.0 * rh)).abs() < 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn in_cell_height(lines: usize) -> f32 {
|
|
||||||
let mut s = blank();
|
|
||||||
s.execute("selection.set", json!({"cell": "B3"})).unwrap();
|
|
||||||
let mut h = harness(s);
|
|
||||||
let text = (1..=lines).map(|n| format!("row {n}")).collect::<Vec<_>>().join("\n");
|
|
||||||
h.state_mut().begin_edit(Some(text.clone()), false);
|
|
||||||
settle(&mut h);
|
|
||||||
assert_eq!(h.state().editor.as_ref().unwrap().text, text, "the text is kept whole");
|
|
||||||
let rect: Rect = h.ctx.data(|d| d.get_temp(egui::Id::new("gridcraft.cell_editor.rect"))).expect("the in-cell editor painted");
|
|
||||||
rect.height()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn in_cell_editor_grows_with_every_line() {
|
|
||||||
// The formula bar stops at five lines and scrolls; the in-cell editor shows every line, as
|
|
||||||
// the grid's own tests ask (`an_unwrapped_multiline_edit_shows_every_line`).
|
|
||||||
let (four, five, nine) = (in_cell_height(4), in_cell_height(5), in_cell_height(9));
|
|
||||||
assert!(five > four + 1.0, "grows with the lines ({four} then {five})");
|
|
||||||
assert!(nine > five + 1.0, "and past five ({five} then {nine})");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn code_editor_indents_and_keeps_indentation() {
|
|
||||||
struct State {
|
|
||||||
text: String,
|
|
||||||
focused: bool,
|
|
||||||
}
|
|
||||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 300.0)).build_ui_state(
|
|
||||||
|ui, s: &mut State| {
|
|
||||||
if ui.ctx().cumulative_pass_nr() == 0 {
|
|
||||||
// egui's own fonts stand in for the system ones under the theme's family names.
|
|
||||||
let mut fonts = egui::FontDefinitions::default();
|
|
||||||
let mono = fonts.families.get(&egui::FontFamily::Monospace).cloned().unwrap_or_default();
|
|
||||||
fonts.families.insert(egui::FontFamily::Name(theme::MONO.into()), mono);
|
|
||||||
ui.ctx().set_fonts(fonts);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let id = egui::Id::new("code");
|
|
||||||
if !s.focused {
|
|
||||||
ui.memory_mut(|m| m.request_focus(id));
|
|
||||||
s.focused = true;
|
|
||||||
}
|
|
||||||
code_editor(ui, id, &mut s.text, Lang::Python, 5);
|
|
||||||
},
|
|
||||||
State { text: String::new(), focused: false },
|
|
||||||
);
|
|
||||||
h.run_steps(2);
|
|
||||||
h.input_mut().events.push(Event::Text("if x:".into()));
|
|
||||||
h.run_steps(1);
|
|
||||||
h.input_mut().events.extend(key(Key::Enter, Modifiers::NONE));
|
|
||||||
h.run_steps(1);
|
|
||||||
h.input_mut().events.push(Event::Text("y = 1".into()));
|
|
||||||
h.run_steps(1);
|
|
||||||
h.input_mut().events.extend(key(Key::Enter, Modifiers::NONE));
|
|
||||||
h.run_steps(1);
|
|
||||||
h.input_mut().events.extend(key(Key::Tab, Modifiers::NONE));
|
|
||||||
h.run_steps(1);
|
|
||||||
h.input_mut().events.push(Event::Text("z".into()));
|
|
||||||
h.run_steps(1);
|
|
||||||
assert_eq!(h.state().text, "if x:\n y = 1\n z");
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
//! Ctrl+Shift+U (Control+Shift+U in Excel for Mac) expands and collapses the formula bar.
|
|
||||||
|
|
||||||
use egui::{Event, Key, Modifiers};
|
|
||||||
use gridcraft_engine::Session;
|
|
||||||
use gridcraft_ui_egui::SheetApp;
|
|
||||||
use gridcraft_ui_egui::keymap::{Shortcut, lookup};
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
|
||||||
|
|
||||||
/// Ctrl+Shift on Windows and Linux sets `ctrl`, `command` and `shift`.
|
|
||||||
const CTRL_SHIFT: Modifiers = Modifiers { alt: false, ctrl: true, shift: true, mac_cmd: false, command: true };
|
|
||||||
/// Control+Shift on a Mac: `ctrl` without `command`.
|
|
||||||
const MAC_CONTROL_SHIFT: Modifiers = Modifiers { alt: false, ctrl: true, shift: true, mac_cmd: false, command: false };
|
|
||||||
|
|
||||||
fn harness() -> Harness {
|
|
||||||
let mut session = Session::new();
|
|
||||||
session.new_workbook();
|
|
||||||
session.execute("cell.set", json!({"cell": "A1", "input": "text"})).unwrap();
|
|
||||||
session.execute("selection.set", json!({"range": "A1"})).unwrap();
|
|
||||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
|
||||||
|ui, app: &mut SheetApp| {
|
|
||||||
let ctx = ui.ctx().clone();
|
|
||||||
app.logic(&ctx);
|
|
||||||
app.ui(ui);
|
|
||||||
},
|
|
||||||
SheetApp::new(session, Default::default()),
|
|
||||||
);
|
|
||||||
h.run_steps(4);
|
|
||||||
h
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ctrl_shift_u(h: &mut Harness, m: Modifiers) {
|
|
||||||
h.input_mut().events.push(Event::Key { key: Key::U, physical_key: Some(Key::U), pressed: true, repeat: false, modifiers: m });
|
|
||||||
h.input_mut().events.push(Event::Key { key: Key::U, physical_key: Some(Key::U), pressed: false, repeat: false, modifiers: m });
|
|
||||||
h.run_steps(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn expanded(h: &Harness) -> bool {
|
|
||||||
h.state().ui.formula_bar_expanded
|
|
||||||
}
|
|
||||||
|
|
||||||
fn underlined(h: &Harness) -> bool {
|
|
||||||
h.state().session.journal.iter().any(|(id, _)| id == "home.underline")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ctrl_shift_u_on_the_grid_expands_and_collapses_the_formula_bar() {
|
|
||||||
let mut h = harness();
|
|
||||||
assert!(!expanded(&h));
|
|
||||||
ctrl_shift_u(&mut h, CTRL_SHIFT);
|
|
||||||
assert!(expanded(&h), "the first Ctrl+Shift+U expands the formula bar");
|
|
||||||
ctrl_shift_u(&mut h, CTRL_SHIFT);
|
|
||||||
assert!(!expanded(&h), "the second Ctrl+Shift+U collapses it");
|
|
||||||
assert!(!underlined(&h), "Ctrl+Shift+U is not Underline");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn control_shift_u_expands_the_formula_bar_in_excel_for_mac() {
|
|
||||||
let mut h = harness();
|
|
||||||
h.ctx.set_os(egui::os::OperatingSystem::Mac);
|
|
||||||
h.run_steps(2);
|
|
||||||
ctrl_shift_u(&mut h, MAC_CONTROL_SHIFT);
|
|
||||||
assert!(expanded(&h));
|
|
||||||
assert!(h.state().editor.is_none(), "Control+Shift+U does not start editing as Control+U does");
|
|
||||||
let expand = Some(Shortcut::Run("view.expandFormulaBar", json!({})));
|
|
||||||
assert_eq!(lookup(Key::U, Some(Key::U), MAC_CONTROL_SHIFT, true), expand);
|
|
||||||
assert_eq!(lookup(Key::U, Some(Key::U), CTRL_SHIFT, false), expand);
|
|
||||||
// Control+U on a Mac still edits the active cell.
|
|
||||||
assert_eq!(lookup(Key::U, Some(Key::U), Modifiers::CTRL, true), Some(Shortcut::Edit));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ctrl_shift_u_while_editing_keeps_the_entry() {
|
|
||||||
let mut h = harness();
|
|
||||||
h.state_mut().begin_edit(Some("draft".into()), false);
|
|
||||||
h.run_steps(2);
|
|
||||||
ctrl_shift_u(&mut h, CTRL_SHIFT);
|
|
||||||
assert!(expanded(&h), "Ctrl+Shift+U works in the cell editor too");
|
|
||||||
let ed = h.state().editor.as_ref().expect("the edit stays open");
|
|
||||||
assert_eq!(ed.text, "draft");
|
|
||||||
ctrl_shift_u(&mut h, CTRL_SHIFT);
|
|
||||||
assert!(!expanded(&h));
|
|
||||||
assert!(h.state().editor.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expand_formula_bar_command_sets_or_toggles_the_state() {
|
|
||||||
let mut h = harness();
|
|
||||||
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"on": true})), Ok(json!({"on": true})));
|
|
||||||
assert!(expanded(&h));
|
|
||||||
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"on": true})), Ok(json!({"on": true})));
|
|
||||||
assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({})), Ok(json!({"on": false})));
|
|
||||||
assert!(!expanded(&h));
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user