190 lines
6.4 KiB
Rust
190 lines
6.4 KiB
Rust
//! How cells look as text: number formats applied, for the grid, clipboard, CSV and agents.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Mutex;
|
|
|
|
use gridcraft_core::{CellRef, RangeRef, Value};
|
|
use gridcraft_model::{Sheet, Workbook};
|
|
use gridcraft_numfmt::{Formatted, NumberFormat, format_value};
|
|
|
|
/// Parsed number formats are cached by code.
|
|
fn parsed(code: &str) -> NumberFormat {
|
|
static CACHE: std::sync::LazyLock<Mutex<HashMap<String, NumberFormat>>> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
|
|
let mut m = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
if let Some(f) = m.get(code) {
|
|
return f.clone();
|
|
}
|
|
let f = NumberFormat::parse(code);
|
|
if m.len() > 4096 {
|
|
m.clear();
|
|
}
|
|
m.insert(code.to_string(), f.clone());
|
|
f
|
|
}
|
|
|
|
pub fn number_format(code: &str) -> NumberFormat {
|
|
parsed(code)
|
|
}
|
|
|
|
/// Formats a value with a format code.
|
|
pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted {
|
|
format_value(v, &parsed(code), wb.date_system)
|
|
}
|
|
|
|
/// What the cell editor and the formula bar start from. Same as `Cell::input_text`, except that a
|
|
/// constant number in a date or time format is shown as a date (as the grid shows it), not as its
|
|
/// serial. When the formatted text would not read back as the same date (for example
|
|
/// `dddd, mmmm d`), an ISO `yyyy-mm-dd` (with `hh:mm:ss` for a time of day; just `hh:mm:ss`
|
|
/// for a time alone) is used instead, and if even that would not read back, the serial.
|
|
pub fn edit_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
|
|
let raw = sheet.input_text(c);
|
|
let Some(cell) = sheet.cell(c) else { return raw };
|
|
let Value::Number(n) = &cell.value else { return raw };
|
|
let code = wb.styles.get(cell.style).num_fmt.as_str();
|
|
if cell.formula.is_some() || !parsed(code).is_date() {
|
|
return raw;
|
|
}
|
|
let reads_back = |s: &str| matches!(gridcraft_core::parse::parse_input(s, wb.date_system).value, Value::Number(m) if (m - n).abs() < 1e-9);
|
|
let shown = format(&cell.value, code, wb).text;
|
|
if reads_back(&shown) {
|
|
return shown;
|
|
}
|
|
let Some(dt) = gridcraft_core::date::datetime_from_serial(wb.date_system, *n) else { return raw };
|
|
let time = format!("{:02}:{:02}:{:02}", dt.hour, dt.minute, dt.second);
|
|
let date = format!("{:04}-{:02}-{:02}", dt.year, dt.month, dt.day);
|
|
let iso = if *n < 1.0 {
|
|
time
|
|
} else if n.fract() == 0.0 {
|
|
date
|
|
} else {
|
|
format!("{date} {time}")
|
|
};
|
|
if reads_back(&iso) { iso } else { raw }
|
|
}
|
|
|
|
/// Whether `c` has a formula that must not be shown: its cell is formatted Hidden on a
|
|
/// protected sheet. A spilled cell follows its anchor.
|
|
pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool {
|
|
if !sheet.is_protected() {
|
|
return false;
|
|
}
|
|
let at = match sheet.cell(c) {
|
|
Some(x) if x.formula.is_some() => c,
|
|
_ => match sheet.spill_ranges.iter().find(|(_, r)| r.contains(c)) {
|
|
Some((anchor, _)) => *anchor,
|
|
None => return false,
|
|
},
|
|
};
|
|
wb.styles.get(sheet.style_id(at)).protection.hidden
|
|
}
|
|
|
|
/// The text a cell shows (full precision General; the grid narrows General to fit the column).
|
|
pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
|
|
let v = sheet.value(c);
|
|
if sheet.show_formulas
|
|
&& let Some(f) = sheet.cell(c).and_then(|x| x.formula.as_ref())
|
|
&& !formula_hidden(wb, sheet, c)
|
|
{
|
|
return format!("={}", f.text);
|
|
}
|
|
let style = wb.styles.get(sheet.style_id(c));
|
|
let f = format(&v, style.num_fmt.as_str(), wb);
|
|
match f.fill {
|
|
Some((_, pos)) => {
|
|
let mut t = f.text;
|
|
if pos <= t.len() && t.is_char_boundary(pos) {
|
|
t.insert(pos, ' ');
|
|
}
|
|
t
|
|
}
|
|
None => f.text,
|
|
}
|
|
}
|
|
|
|
/// Tab-separated text of a range, as the system clipboard gets it.
|
|
pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String {
|
|
let Some(sh) = wb.sheet(sheet) else { return String::new() };
|
|
let mut out = String::new();
|
|
let r = clipboard_range(r);
|
|
for row in r.start.row..=r.end.row {
|
|
if sh.is_row_hidden(row) {
|
|
continue;
|
|
}
|
|
let mut first = true;
|
|
for col in r.start.col..=r.end.col {
|
|
if !first {
|
|
out.push('\t');
|
|
}
|
|
first = false;
|
|
let t = cell_text(wb, sh, CellRef::new(row, col));
|
|
if t.contains(['\t', '\n', '"']) {
|
|
out.push('"');
|
|
out.push_str(&t.replace('"', "\"\""));
|
|
out.push('"');
|
|
} else {
|
|
out.push_str(&t);
|
|
}
|
|
}
|
|
out.push('\n');
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The existing plain-text clipboard limit, shared with its HTML representation.
|
|
pub(crate) fn clipboard_range(r: RangeRef) -> RangeRef {
|
|
if r.count() > 2_000_000 { RangeRef::new(r.start, r.start.offset_clamped(1999, 999)) } else { r }
|
|
}
|
|
|
|
/// Status-bar statistics for a selection: average, count (non-empty), numerical count, min,
|
|
/// max, sum.
|
|
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize)]
|
|
pub struct Stats {
|
|
pub count: u64,
|
|
pub numeric: u64,
|
|
pub sum: f64,
|
|
pub min: f64,
|
|
pub max: f64,
|
|
pub average: Option<f64>,
|
|
}
|
|
|
|
pub fn stats(sheet: &Sheet, ranges: &[RangeRef]) -> Stats {
|
|
let mut s = Stats { min: f64::INFINITY, max: f64::NEG_INFINITY, ..Default::default() };
|
|
let mut seen = std::collections::HashSet::new();
|
|
for r in ranges {
|
|
let r = match sheet.used_range().and_then(|u| r.intersection(&u)) {
|
|
Some(x) => x,
|
|
None => continue,
|
|
};
|
|
let mut visit = |c: CellRef, v: &Value| {
|
|
if !seen.insert(c) || sheet.is_row_hidden(c.row) {
|
|
return;
|
|
}
|
|
if v.is_empty() {
|
|
return;
|
|
}
|
|
s.count += 1;
|
|
if let Value::Number(n) = v {
|
|
s.numeric += 1;
|
|
s.sum += n;
|
|
s.min = s.min.min(*n);
|
|
s.max = s.max.max(*n);
|
|
}
|
|
};
|
|
for (c, cell) in sheet.cells.iter_range(r) {
|
|
visit(c, &cell.value);
|
|
}
|
|
for (c, v) in sheet.spill.range(r.start..=r.end) {
|
|
if r.contains(*c) {
|
|
visit(*c, v);
|
|
}
|
|
}
|
|
}
|
|
if s.numeric > 0 {
|
|
s.average = Some(s.sum / s.numeric as f64);
|
|
} else {
|
|
s.min = 0.0;
|
|
s.max = 0.0;
|
|
}
|
|
s
|
|
}
|