Skip to main content

ravenrs_sheet/engine/
tables.rs

1//! Table styles: how a cell inside a table looks (our own palettes, keyed by the familiar
2//! `TableStyleLight1…21 / Medium1…28 / Dark1…11` names so files round-trip).
3
4use crate::base::{CellRef, RangeRef};
5use crate::model::{BorderLine, BorderStyle, Cell, Color, Fill, Formula, PatternType, Style, Table, Workbook};
6
7/// (family, number) from a style name, e.g. `TableStyleMedium2` → ("Medium", 2).
8pub fn parse_style_name(name: &str) -> (&'static str, u32) {
9    let rest = name.strip_prefix("TableStyle").unwrap_or(name);
10    for fam in ["Light", "Medium", "Dark"] {
11        if let Some(n) = rest.strip_prefix(fam) {
12            let k = n.parse::<u32>().unwrap_or(2).max(1); // the gallery starts at 1; 0 would underflow `n - 1`
13            return (fam, k);
14        }
15    }
16    ("Medium", 2)
17}
18
19/// Accent theme slot (4..=9) for a style number; 1 and every 7th style are neutral (dk1).
20fn accent_for(n: u32) -> Option<u8> {
21    let k = (n.saturating_sub(1)) % 7;
22    if k == 0 { None } else { Some(3 + k as u8) }
23}
24
25/// Header/band/body colours for a table style.
26pub struct TableLook {
27    pub header_fill: Option<Color>,
28    pub header_font: Color,
29    pub header_bold: bool,
30    pub band_fill: Option<Color>,
31    pub body_fill: Option<Color>,
32    pub line: Option<Color>,
33    pub total_top: Option<Color>,
34}
35
36pub fn look(style: &str) -> TableLook {
37    let (fam, n) = parse_style_name(style);
38    let acc = accent_for(n);
39    let base = |tint: i16| match acc {
40        Some(a) => Color::Theme(a, tint),
41        None => Color::Theme(1, if tint == 0 { 0 } else { tint.clamp(-1000, 1000) }),
42    };
43    let neutral_light = |tint: i16| match acc {
44        Some(a) => Color::Theme(a, tint),
45        None => Color::Theme(0, -((1000 - tint).clamp(0, 1000) / 6)),
46    };
47    match fam {
48        "Light" => {
49            let group = (n - 1) / 7; // 0: lines only, 1: banded header line, 2: grid
50            TableLook {
51                header_fill: None,
52                header_font: if group == 0 { base(-250) } else { Color::Auto },
53                header_bold: true,
54                band_fill: Some(neutral_light(800)),
55                body_fill: None,
56                line: Some(base(0)),
57                total_top: Some(base(0)),
58            }
59        }
60        "Dark" => TableLook {
61            header_fill: Some(Color::Theme(1, 0)),
62            header_font: Color::Theme(0, 0),
63            header_bold: true,
64            band_fill: Some(base(-250)),
65            body_fill: Some(base(0)),
66            line: None,
67            total_top: Some(Color::Theme(0, 0)),
68        },
69        _ => TableLook {
70            header_fill: Some(base(0)),
71            header_font: Color::Theme(0, 0),
72            header_bold: true,
73            band_fill: Some(neutral_light(800)),
74            body_fill: None,
75            line: if (n - 1) / 7 == 0 { None } else { Some(base(400)) },
76            total_top: Some(base(0)),
77        },
78    }
79}
80
81/// The style a table gives a cell (merged over the cell's own style: explicit fills/fonts win).
82pub fn table_cell_style(wb: &Workbook, sheet: usize, t: &Table, c: CellRef) -> Style {
83    let own = wb.sheet(sheet).map(|sh| wb.styles.get(sh.style_id(c)).clone()).unwrap_or_default();
84    let mut s = own.clone();
85    let lk = look(&t.style);
86    let is_header = t.header_row && c.row == t.range.start.row;
87    let is_total = t.totals_row && c.row == t.range.end.row;
88    let data_row = c.row.saturating_sub(t.range.start.row + t.header_row as u32);
89    let data_col = c.col - t.range.start.col;
90    let explicit_fill = own.fill.pattern != PatternType::None;
91    if is_header {
92        if let Some(f) = lk.header_fill
93            && !explicit_fill
94        {
95            s.fill = Fill::solid(f);
96        }
97        if own.font.color == Color::Auto {
98            s.font.color = lk.header_font;
99        }
100        s.font.bold = s.font.bold || lk.header_bold;
101        if let Some(l) = lk.line {
102            s.borders.bottom = BorderLine { style: BorderStyle::Thin, color: l };
103        }
104    } else if is_total {
105        s.font.bold = true;
106        if let Some(l) = lk.total_top {
107            s.borders.top = BorderLine { style: BorderStyle::Double, color: l };
108        }
109    } else {
110        let band = (t.banded_rows && data_row.is_multiple_of(2)) || (t.banded_cols && data_col.is_multiple_of(2));
111        if !explicit_fill {
112            if band {
113                if let Some(f) = lk.band_fill {
114                    s.fill = Fill::solid(f);
115                }
116            } else if let Some(f) = lk.body_fill {
117                s.fill = Fill::solid(f);
118            }
119        }
120        if let Some(l) = lk.line
121            && t.style.contains("Light")
122            && c.row == t.range.end.row
123        {
124            s.borders.bottom = BorderLine { style: BorderStyle::Thin, color: l };
125        }
126    }
127    if (t.first_col && c.col == t.range.start.col) || (t.last_col && c.col == t.range.end.col) {
128        s.font.bold = true;
129    }
130    if t.style.starts_with("TableStyleDark") && !is_header && own.font.color == Color::Auto {
131        s.font.color = Color::Theme(0, 0);
132    }
133    s
134}
135
136/// Gallery list of style names.
137pub fn gallery() -> Vec<String> {
138    let mut v = Vec::new();
139    for n in 1..=21 {
140        v.push(format!("TableStyleLight{n}"));
141    }
142    for n in 1..=28 {
143        v.push(format!("TableStyleMedium{n}"));
144    }
145    for n in 1..=11 {
146        v.push(format!("TableStyleDark{n}"));
147    }
148    v
149}
150
151/// Editing a table's header row renames the column, as in Excel: an empty or repeated header
152/// gets a unique name written back into the cell, and structured references follow the rename.
153pub(crate) fn sync_headers(cx: &mut crate::engine::cmd::Ctx) {
154    if cx.wb.sheets.iter().all(|s| s.tables.is_empty()) {
155        return;
156    }
157    // (sheet, table name, table range, old column name, new column name)
158    let mut renames: Vec<(usize, String, RangeRef, String, String)> = Vec::new();
159    let heads: Vec<(usize, RangeRef)> = (0..cx.wb.sheets.len())
160        .flat_map(|si| cx.wb.sheet(si).into_iter().flat_map(move |sh| sh.tables.iter().filter(|t| t.header_row).map(move |t| (si, t.range))))
161        .map(|(si, r)| (si, RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col))))
162        .collect();
163    // A structural edit can move anything into a header row: then every header is checked.
164    let changed: std::collections::BTreeSet<(usize, CellRef)> = if cx.structural {
165        heads.iter().flat_map(|(s, r)| r.iter().map(move |c| (*s, c))).collect()
166    } else {
167        cx.changed.iter().filter(|(si, c)| heads.iter().any(|(s, r)| s == si && r.contains(*c))).copied().collect()
168    };
169    // Per table: lower-case column name → column, and the next number to try for a repeated name
170    // (both kept up to date, so a wide header row stays linear).
171    type Names = (std::collections::HashMap<String, usize>, std::collections::HashMap<String, usize>);
172    let mut names: std::collections::HashMap<(usize, usize), Names> = std::collections::HashMap::new();
173    for (si, c) in changed {
174        let Some(sh) = cx.wb.sheet(si) else { continue };
175        let Some(ti) = sh.tables.iter().position(|t| t.header_row && c.row == t.range.start.row && t.range.contains(c)) else { continue };
176        let Some(t) = sh.tables.get(ti) else { continue };
177        let col = (c.col - t.range.start.col) as usize;
178        let Some(old) = t.columns.get(col).map(|x| x.name.clone()) else { continue };
179        // A text header names its column as it is: a number format only changes how it looks (an
180        // accounting format pads text with spaces), and the file writes the name into the cell.
181        let value = sh.value(c);
182        let text = match value.as_text() {
183            Some(t) => t.to_string(),
184            None => crate::engine::display::cell_text(&cx.wb, sh, c),
185        };
186        // Headers hold constant text: a formula (or a spilled value) there becomes its text.
187        let is_text = value.is_text() && sh.cell(c).is_some_and(|x| x.formula.is_none());
188        if text == old && is_text {
189            continue;
190        }
191        let (taken, next) = names
192            .entry((si, ti))
193            .or_insert_with(|| (t.columns.iter().enumerate().map(|(i, x)| (x.name.to_lowercase(), i)).collect(), Default::default()));
194        if taken.get(&old.to_lowercase()) == Some(&col) {
195            taken.remove(&old.to_lowercase());
196        }
197        let base = if text.trim().is_empty() { format!("Column{}", col + 1) } else { text.clone() };
198        let k = next.entry(base.to_lowercase()).or_insert(2);
199        let mut name = base.clone();
200        while taken.contains_key(&name.to_lowercase()) {
201            name = format!("{base}{k}");
202            *k += 1;
203        }
204        taken.insert(name.to_lowercase(), col);
205        let (table, range) = (t.name.clone(), t.range);
206        let Some(sh) = cx.wb.sheet_mut(si) else { continue };
207        if let Some(x) = sh.tables.get_mut(ti).and_then(|t| t.columns.get_mut(col)) {
208            x.name = name.clone();
209        }
210        // Headers are text: a number typed there, or a generated name, is written back as text.
211        if name != text || !is_text {
212            let style = sh.style_id(c);
213            sh.cells.set(c, Cell { value: crate::base::Value::text(name.as_str()), style, ..Default::default() });
214        }
215        if !old.eq_ignore_ascii_case(&name) {
216            renames.push((si, table, range, old, name));
217        }
218    }
219    if renames.is_empty() {
220        return;
221    }
222    // Structured references follow the new names.
223    for i in 0..cx.wb.sheets.len() {
224        let formulas: Vec<(CellRef, std::sync::Arc<Formula>)> =
225            cx.wb.sheets.get(i).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
226        for (c, f) in formulas {
227            let Some(e) = f.expr() else { continue };
228            let mut hit = false;
229            let ne = e.map(&mut |x| match x {
230                crate::formula::Expr::Struct(mut st) => {
231                    for (si, table, range, old, name) in &renames {
232                        let this = if st.table.is_empty() { i == *si && range.contains(c) } else { st.table.eq_ignore_ascii_case(table) };
233                        if this {
234                            for end in [&mut st.col_start, &mut st.col_end].into_iter().flatten() {
235                                if end.eq_ignore_ascii_case(old) {
236                                    *end = name.clone();
237                                    hit = true;
238                                }
239                            }
240                        }
241                    }
242                    crate::formula::Expr::Struct(st)
243                }
244                o => o,
245            });
246            if hit && let Some(cell) = cx.wb.sheet_mut(i).and_then(|s| s.cells.get_mut(c)) {
247                cell.formula = Some(std::sync::Arc::new(Formula::from_expr(ne)));
248            }
249        }
250    }
251    cx.structural = true;
252}
253
254#[cfg(test)]
255mod tests {
256    use crate::base::Value;
257    use serde_json::json;
258
259    use crate::engine::Session;
260
261    #[test]
262    fn editing_a_header_renames_the_column() {
263        let mut s = Session::new();
264        s.new_workbook();
265        s.execute("range.setValues", json!({"range": "A1", "values": [["a", "b", "c"], [1, 2, 3]]})).unwrap();
266        s.execute("insert.table", json!({"range": "A1:C2", "header": true})).unwrap();
267        s.execute("cell.set", json!({"cell": "E1", "input": "=SUM(Table1[a])"})).unwrap();
268        s.execute("cell.set", json!({"cell": "A1", "input": "zz"})).unwrap();
269        s.execute("edit.clearContents", json!({"range": "B1"})).unwrap();
270        s.execute("cell.set", json!({"cell": "C1", "input": "ZZ"})).unwrap();
271        let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
272        assert_eq!(get(&mut s, "E1")["formula"], "=SUM(Table1[zz])");
273        assert_eq!(get(&mut s, "E1")["value"], json!(1.0));
274        let d = s.doc().unwrap();
275        let sh = d.wb.sheet(0).unwrap();
276        let names: Vec<&str> = sh.tables[0].columns.iter().map(|c| c.name.as_str()).collect();
277        assert_eq!(names, ["zz", "Column2", "ZZ2"]);
278        assert_eq!(sh.value(crate::base::CellRef::new(0, 1)), Value::from("Column2"));
279        assert_eq!(sh.value(crate::base::CellRef::new(0, 2)), Value::from("ZZ2"));
280        s.execute("range.setValues", json!({"range": "A1", "values": [["x", "x", 5]]})).unwrap();
281        let sh = s.doc().unwrap().wb.sheet(0).unwrap();
282        let names: Vec<&str> = sh.tables[0].columns.iter().map(|c| c.name.as_str()).collect();
283        assert_eq!(names, ["x", "x2", "5"]);
284        assert_eq!(sh.value(crate::base::CellRef::new(0, 2)), Value::from("5"));
285    }
286
287    #[test]
288    fn go_to_a_table_selects_its_data() {
289        let mut s = Session::new();
290        s.new_workbook();
291        s.execute("range.setValues", json!({"range": "A1", "values": [["a", "b"], [1, 2], [3, 4]]})).unwrap();
292        s.execute("insert.table", json!({"range": "A1:B3", "header": true})).unwrap();
293        assert_eq!(s.execute("edit.goTo", json!({"reference": "Table1"})).unwrap()["selection"], "A2:B3");
294    }
295}
296
297#[cfg(test)]
298mod style_tests {
299    use super::*;
300
301    #[test]
302    fn zero_numbered_styles_do_not_underflow() {
303        assert_eq!(parse_style_name("TableStyleLight0"), ("Light", 1));
304        assert_eq!(parse_style_name("TableStyleMedium0"), ("Medium", 1));
305        for name in ["TableStyleLight0", "TableStyleMedium0", "TableStyleDark0"] {
306            let _ = look(name);
307        }
308    }
309}