Skip to main content

ravenrs_sheet/engine/
display.rs

1//! How cells look as text: number formats applied, for the grid, clipboard, CSV and agents.
2
3use std::collections::HashMap;
4use std::sync::Mutex;
5
6use crate::base::{CellRef, RangeRef, Value};
7use crate::model::{Sheet, Workbook};
8use crate::numfmt::{Formatted, NumberFormat, format_value};
9
10/// Parsed number formats are cached by code.
11fn parsed(code: &str) -> NumberFormat {
12    static CACHE: std::sync::LazyLock<Mutex<HashMap<String, NumberFormat>>> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
13    let lock = || CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
14    if let Some(f) = lock().get(code) {
15        return f.clone();
16    }
17    // Parse outside the lock, so a slow code never blocks the other threads' lookups.
18    let f = NumberFormat::parse(code);
19    let mut m = lock();
20    if m.len() > 4096 {
21        m.clear();
22    }
23    m.insert(code.to_string(), f.clone());
24    f
25}
26
27pub fn number_format(code: &str) -> NumberFormat {
28    parsed(code)
29}
30
31/// Formats a value with a format code.
32pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted {
33    format_value(v, &parsed(code), wb.date_system)
34}
35
36/// Whether typing `t` back into a General cell would not give the text `t` again (it would parse
37/// as a number, boolean, error, date or formula, or lose a leading apostrophe). Such text is shown
38/// for editing with a leading `'`, as Excel does for text carrying a quote prefix, so that
39/// committing the editor unchanged keeps the cell as text.
40fn text_needs_quote_prefix(t: &str, sys: crate::base::date::DateSystem) -> bool {
41    if t.is_empty() {
42        return false;
43    }
44    let mut chars = t.chars();
45    let (first, second) = (chars.next(), chars.next());
46    if matches!(first, Some('=' | '\'')) {
47        return true;
48    }
49    if matches!(first, Some('+' | '-')) && second.is_some_and(|c| c.is_ascii_alphabetic() || c == '(') {
50        return true;
51    }
52    !matches!(crate::base::parse::parse_input(t, sys).value, Value::Text(ref x) if x.as_ref() == t)
53}
54
55/// What the cell editor and the formula bar start from. Same as `Cell::input_text`, except that a
56/// constant number in a date or time format is shown as a date (as the grid shows it), not as its
57/// serial. When the formatted text would not read back as the same date (for example
58/// `dddd, mmmm d`), an ISO `yyyy-mm-dd` (with `hh:mm:ss` for a time of day; just `hh:mm:ss`
59/// for a time alone) is used instead, and if even that would not read back, the serial.
60pub fn edit_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
61    let raw = sheet.input_text(c);
62    let Some(cell) = sheet.cell(c) else { return raw };
63    if let Value::Text(t) = &cell.value
64        && cell.formula.is_none()
65        && wb.styles.get(cell.style).num_fmt.as_str() != "@"
66        && text_needs_quote_prefix(t, wb.date_system)
67    {
68        return format!("'{raw}");
69    }
70    let Value::Number(n) = &cell.value else { return raw };
71    let code = wb.styles.get(cell.style).num_fmt.as_str();
72    if cell.formula.is_some() || !parsed(code).is_date() {
73        return raw;
74    }
75    let reads_back = |s: &str| matches!(crate::base::parse::parse_input(s, wb.date_system).value, Value::Number(m) if (m - n).abs() < 1e-9);
76    let shown = format(&cell.value, code, wb).text;
77    if reads_back(&shown) {
78        return shown;
79    }
80    let Some(dt) = crate::base::date::datetime_from_serial(wb.date_system, *n) else { return raw };
81    let time = format!("{:02}:{:02}:{:02}", dt.hour, dt.minute, dt.second);
82    let date = format!("{:04}-{:02}-{:02}", dt.year, dt.month, dt.day);
83    let iso = if *n < 1.0 {
84        time
85    } else if n.fract() == 0.0 {
86        date
87    } else {
88        format!("{date} {time}")
89    };
90    if reads_back(&iso) { iso } else { raw }
91}
92
93/// Whether `c` has a formula or Python code that must not be shown: its cell is formatted
94/// Hidden on a protected sheet. A spilled cell follows its anchor.
95pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool {
96    if !sheet.is_protected() {
97        return false;
98    }
99    let at = match sheet.cell(c) {
100        Some(x) if x.formula.is_some() || x.py.is_some() => c,
101        _ => match sheet.spill_ranges.iter().find(|(_, r)| r.contains(c)) {
102            Some((anchor, _)) => *anchor,
103            None => return false,
104        },
105    };
106    wb.styles.get(sheet.style_id(at)).protection.hidden
107}
108
109/// The text a cell shows (full precision General; the grid narrows General to fit the column).
110pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
111    if sheet.show_formulas
112        && let Some(f) = sheet.cell(c).and_then(|x| x.formula.as_ref())
113        && !formula_hidden(wb, sheet, c)
114    {
115        return format!("={}", f.text);
116    }
117    value_text(wb, sheet, c)
118}
119
120/// A cell's value with its number format applied, independent of column width: no `#####`
121/// and no General narrowing (Save As CSV/Text writes this). Formulas give their result.
122pub fn value_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
123    let style = wb.styles.get(sheet.style_id(c));
124    value_text_as(wb, &sheet.value(c), style.num_fmt.as_str())
125}
126
127/// [`value_text`] for a value and a format code given apart from a cell (print takes the code
128/// from the cell's effective look, which a conditional format can override).
129pub fn value_text_as(wb: &Workbook, v: &Value, code: &str) -> String {
130    let f = format(v, code, wb);
131    match f.fill {
132        Some((_, pos)) => {
133            let mut t = f.text;
134            if pos <= t.len() && t.is_char_boundary(pos) {
135                t.insert(pos, ' ');
136            }
137            t
138        }
139        None => f.text,
140    }
141}
142
143/// Tab-separated text of a range, as the system clipboard gets it.
144pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String {
145    let Some(sh) = wb.sheet(sheet) else { return String::new() };
146    let mut out = String::new();
147    let r = clipboard_range(r);
148    for row in r.start.row..=r.end.row {
149        if sh.is_row_hidden(row) {
150            continue;
151        }
152        let mut first = true;
153        for col in r.start.col..=r.end.col {
154            if !first {
155                out.push('\t');
156            }
157            first = false;
158            let t = cell_text(wb, sh, CellRef::new(row, col));
159            // Like Excel, only text with a tab or line break is quoted (its quotes doubled);
160            // `say "hi"` is copied as it is (#2225).
161            if t.contains(['\t', '\n', '\r']) {
162                out.push('"');
163                out.push_str(&t.replace('"', "\"\""));
164                out.push('"');
165            } else {
166                out.push_str(&t);
167            }
168        }
169        out.push('\n');
170    }
171    out
172}
173
174/// The existing plain-text clipboard limit, shared with its HTML representation.
175pub(crate) fn clipboard_range(r: RangeRef) -> RangeRef {
176    if r.count() > 2_000_000 { RangeRef::new(r.start, r.start.offset_clamped(1999, 999)) } else { r }
177}
178
179/// Status-bar statistics for a selection: average, count (non-empty), numerical count, min,
180/// max, sum.
181#[derive(Clone, Debug, Default, PartialEq, serde::Serialize)]
182pub struct Stats {
183    pub count: u64,
184    pub numeric: u64,
185    pub sum: f64,
186    pub min: f64,
187    pub max: f64,
188    pub average: Option<f64>,
189}
190
191pub fn stats(sheet: &Sheet, ranges: &[RangeRef]) -> Stats {
192    let mut s = Stats { min: f64::INFINITY, max: f64::NEG_INFINITY, ..Default::default() };
193    let mut seen = std::collections::HashSet::new();
194    for r in ranges {
195        let r = match sheet.used_range().and_then(|u| r.intersection(&u)) {
196            Some(x) => x,
197            None => continue,
198        };
199        let mut visit = |c: CellRef, v: &Value| {
200            if !seen.insert(c) || sheet.is_row_hidden(c.row) {
201                return;
202            }
203            if v.is_empty() {
204                return;
205            }
206            s.count += 1;
207            if let Value::Number(n) = v {
208                s.numeric += 1;
209                s.sum += n;
210                s.min = s.min.min(*n);
211                s.max = s.max.max(*n);
212            }
213        };
214        for (c, cell) in sheet.cells.iter_range(r) {
215            visit(c, &cell.value);
216        }
217        for (c, v) in sheet.spill.range(r.start..=r.end) {
218            if r.contains(*c) {
219                visit(*c, v);
220            }
221        }
222    }
223    if s.numeric > 0 {
224        s.average = Some(s.sum / s.numeric as f64);
225    } else {
226        s.min = 0.0;
227        s.max = 0.0;
228    }
229    s
230}