Skip to main content

vla_lang/
view.rs

1//! The view record (KERNEL.4, 2026-10-07): one window of the sheet model as
2//! lines, the first implementation of the kernel's projections seam
3//! ([`crate::projection::Projection`]; the design is `web/CALLOSUM.md` §7,
4//! decisions 1 and 2, and §8, slice 1).
5//!
6//! A view is a pure function of the model and a window: no handle kept, no
7//! state, nothing read back from the `.xlsx` bytes. The model is the one the
8//! writer builds (`build::build_workbook`), so a door that translates and
9//! builds a program draws it without writing a file, and a viewport
10//! (KERNEL.5) draws from this record alone. One form a line, in the proof
11//! corpus's notation, as `reflect` prints a file:
12//!
13//! ```text
14//! (sheet "Frazaro" visible)          every sheet of the model, in tab order, with
15//!                                    its own state: visible, hidden, very-hidden
16//! (window "Output" "A1:F20")         the window, its sheet as the model spells it
17//! (extent "Output" "B1:D4")          the sheet's used rectangle, or none
18//! (gridlines "Output" on)
19//! (column "Frazaro" "B" 72 shown 1)  a column of the window with settings:
20//!                                    width or none, shown or hidden, format or none
21//! (row "Board" 3 30 shown)           a row of the window with settings (KERNEL.22):
22//!                                    height or none, shown or hidden
23//! (format 1 "F7F4FC" text nowrap)    format 0 and every format the window uses:
24//!                                    fill or none, general or text, wrap or nowrap
25//! (look "Board" 1 2)                 the sheet's look map (KERNEL.22): a cell
26//!                                    holding the value draws with the format
27//! (cell "Output" "B2" 5)             a value, spelled as reflect spells it
28//! (formula "Output" "B3" "=B2*2")    a formula's text as the bar shows it
29//! (value "Output" "B3" 10)           its computed value, once values exist
30//! (style "Output" "B2" 1)            the cell's format, when it is not 0
31//! (sentence "Output" "B2" 4)         the row of the sentence that wrote the cell
32//! ```
33//!
34//! The cells come in row-major order, each `cell` or `formula` row followed
35//! by its `value` row (a formula's, once values exist), its `style` row and
36//! its `sentence` row. The `cell` and `formula` rows are the reader's own,
37//! spelled by `reflect::print`, which is the free oracle: the view of a
38//! built model's sheet, whole, is `reflect`'s reading of the file the
39//! writer writes from that model, row for row. A `cell` row is a value the
40//! sentences put there, a `formula` row is text the host would compute, and
41//! the record never shows one as the other. Since recalculation landed
42//! (KERNEL.7, 2026-10-08; `Alonzo/SPEC.md` section 4.5 and decision 14) a
43//! `(value "<sheet>" "<addr>" <v>)` row follows each formula row when the
44//! caller computed the model ([`view_text_valued`], a [`Grid`] holding a
45//! [`Calc`]): the value spelled as a `cell` row's is, or
46//! `(not-computed "<name>")` for a formula this version does not compute,
47//! naming the function or the construct, `(not-computed cycle)` for one in
48//! a cycle. The bare [`view_text`] prints no value row, which is what a
49//! grid shows before its first step.
50
51use std::collections::BTreeSet;
52
53use crate::calc::Values;
54use crate::messages::{raise, Refusal};
55use crate::projection::{Projection, Window};
56use crate::rows::{line as relation, quoted};
57use crate::rows::{Row, Value};
58use crate::sheet::{
59    cell_ref, column_letters, formula_text, number_text, parse_a1_range, shared_masters, A1Range,
60    Cell, Column, Content, NumFmt, Sheet, Style, Workbook,
61};
62
63/// The grid: the view record of one window, the first projection. With the
64/// values of a recalculation over the same model (a [`crate::calc::Calc`],
65/// or an engine's machine since KERNEL.22), each formula row is followed by
66/// its `value` row; without them, the record shows formulas as text alone.
67#[derive(Clone, Copy, Debug, Default)]
68pub struct Grid<'v> {
69    pub values: Option<&'v dyn Values>,
70}
71
72impl<'v> Grid<'v> {
73    /// The grid over a computed model.
74    pub fn valued(values: &'v dyn Values) -> Grid<'v> {
75        Grid {
76            values: Some(values),
77        }
78    }
79}
80
81impl Projection for Grid<'_> {
82    fn name(&self) -> &str {
83        "grid"
84    }
85
86    fn project(&self, model: &Workbook, window: &Window, out: &mut dyn FnMut(&str)) {
87        for s in &model.sheets {
88            out(&relation(&Row::Sheet {
89                name: &s.name,
90                visibility: s.visibility,
91            }));
92        }
93        let Some(i) = model.find_sheet(&window.sheet) else {
94            // A window onto no sheet of the model, which `window_of` never
95            // makes: the sheet rows and the window asked for, nothing more.
96            out(&format!(
97                "(window {} {})",
98                quoted(&window.sheet),
99                quoted(&window.range.text())
100            ));
101            return;
102        };
103        let sheet = &model.sheets[i];
104        let name = sheet.name.as_str();
105        let range = window.range;
106        out(&format!(
107            "(window {} {})",
108            quoted(name),
109            quoted(&range.text())
110        ));
111        match extent_of(sheet) {
112            Some(e) => out(&format!("(extent {} {})", quoted(name), quoted(&e.text()))),
113            None => out(&format!("(extent {} none)", quoted(name))),
114        }
115        out(&format!(
116            "(gridlines {} {})",
117            quoted(name),
118            if sheet.gridlines { "on" } else { "off" }
119        ));
120        let mut columns: Vec<&Column> = sheet
121            .columns
122            .iter()
123            .filter(|c| c.index >= range.left && c.index <= range.right)
124            .collect();
125        columns.sort_by_key(|c| c.index);
126        let mut used: BTreeSet<u32> = BTreeSet::new();
127        used.insert(0);
128        for c in &columns {
129            let width = c
130                .width
131                .and_then(number_text)
132                .unwrap_or_else(|| "none".to_string());
133            let style = match c.style {
134                Some(s) => {
135                    used.insert(s);
136                    s.to_string()
137                }
138                None => "none".to_string(),
139            };
140            out(&format!(
141                "(column {} {} {} {} {})",
142                quoted(name),
143                quoted(&column_letters(c.index)),
144                width,
145                if c.hidden { "hidden" } else { "shown" },
146                style
147            ));
148        }
149        // The rows of the window with settings, the column rows' twins
150        // (KERNEL.22; Alonzo/SPEC.md decision 19).
151        for (n, r) in sheet.rows.range(range.top..=range.bottom) {
152            let height = r
153                .height
154                .and_then(number_text)
155                .unwrap_or_else(|| "none".to_string());
156            out(&format!(
157                "(row {} {} {} {})",
158                quoted(name),
159                n,
160                height,
161                if r.hidden { "hidden" } else { "shown" }
162            ));
163        }
164        let cells: Vec<(&(u32, u32), &Cell)> = sheet
165            .cells
166            .range((range.top, 0)..=(range.bottom, u32::MAX))
167            .filter(|((_, col), _)| *col >= range.left && *col <= range.right)
168            .collect();
169        for (_, cell) in &cells {
170            used.insert(cell.style);
171        }
172        for (_, index) in &sheet.looks {
173            used.insert(*index);
174        }
175        for index in used {
176            out(&format_row(index, model.styles.xfs().get(index as usize)));
177        }
178        // The sheet's look map, a value to the format a cell holding it draws
179        // with (KERNEL.22; Alonzo/SPEC.md decision 19).
180        for (value, index) in &sheet.looks {
181            out(&format!("(look {} {} {})", quoted(name), value, index));
182        }
183        let masters = shared_masters(sheet);
184        // A sheet whose cells are newer where its values say (KERNEL.27): an
185        // engine's twin, read through its values' own content.
186        let lags = self.values.filter(|v| v.lags(i));
187        for ((row, col), cell) in cells {
188            let addr = cell_ref(*row, *col);
189            let newer = lags.and_then(|v| v.content((i, *row, *col)));
190            let printed = match newer.as_ref().unwrap_or(&cell.content) {
191                Content::Text(t) => {
192                    let value = Value::Text(t.clone());
193                    out(&relation(&Row::Cell {
194                        sheet: name,
195                        addr: &addr,
196                        value: &value,
197                    }));
198                    true
199                }
200                Content::Number(n) => {
201                    // What the writer puts in the file: the number's text,
202                    // or Excel's #NUM! for one no cell can hold.
203                    let value = match number_text(*n) {
204                        Some(text) => Value::Number(text),
205                        None => Value::Error("#NUM!".to_string()),
206                    };
207                    out(&relation(&Row::Cell {
208                        sheet: name,
209                        addr: &addr,
210                        value: &value,
211                    }));
212                    true
213                }
214                Content::Bool(b) => {
215                    let value = Value::Bool(*b);
216                    out(&relation(&Row::Cell {
217                        sheet: name,
218                        addr: &addr,
219                        value: &value,
220                    }));
221                    true
222                }
223                Content::Error(e) => {
224                    let value = Value::Error(e.clone());
225                    out(&relation(&Row::Cell {
226                        sheet: name,
227                        addr: &addr,
228                        value: &value,
229                    }));
230                    true
231                }
232                other => match formula_text(other, *row, *col, &masters) {
233                    Some(text) => {
234                        let text = format!("={text}");
235                        out(&relation(&Row::Formula {
236                            sheet: name,
237                            addr: &addr,
238                            text: &text,
239                        }));
240                        if let Some(computed) =
241                            self.values.and_then(|calc| calc.computed((i, *row, *col)))
242                        {
243                            out(&format!(
244                                "(value {} {} {})",
245                                quoted(name),
246                                quoted(&addr),
247                                computed.spell()
248                            ));
249                        }
250                        true
251                    }
252                    None => false,
253                },
254            };
255            if !printed {
256                continue;
257            }
258            if cell.style != 0 {
259                out(&format!(
260                    "(style {} {} {})",
261                    quoted(name),
262                    quoted(&addr),
263                    cell.style
264                ));
265            }
266            if let Some(r) = sheet.sentences.get(&(*row, *col)) {
267                out(&format!(
268                    "(sentence {} {} {})",
269                    quoted(name),
270                    quoted(&addr),
271                    r
272                ));
273            }
274        }
275    }
276}
277
278/// A `format` row: the index, the fill as six hex digits or `none`, the
279/// number format, and whether text wraps. An index the style table does not
280/// hold reads as the default format.
281fn format_row(index: u32, style: Option<&Style>) -> String {
282    let default = Style::default();
283    let s = style.unwrap_or(&default);
284    let fill = match s.fill {
285        Some(rgb) => quoted(&format!("{:02X}{:02X}{:02X}", rgb.0, rgb.1, rgb.2)),
286        None => "none".to_string(),
287    };
288    let num = match s.num_fmt {
289        NumFmt::General => "general",
290        NumFmt::Text => "text",
291    };
292    format!(
293        "(format {index} {fill} {num} {})",
294        if s.wrap { "wrap" } else { "nowrap" }
295    )
296}
297
298/// The sheet's used rectangle, or `None` for a sheet with no cell.
299fn extent_of(sheet: &Sheet) -> Option<A1Range> {
300    sheet
301        .extent()
302        .map(|((top, left), (bottom, right))| A1Range {
303            top,
304            left,
305            bottom,
306            right,
307        })
308}
309
310/// The window over a whole sheet: its extent, or `A1` alone for a sheet
311/// with no cell.
312pub fn whole(sheet: &Sheet) -> A1Range {
313    extent_of(sheet).unwrap_or(A1Range {
314        top: 1,
315        left: 1,
316        bottom: 1,
317        right: 1,
318    })
319}
320
321/// The window a door asked for, against the model: the sheet by name as
322/// Excel compares names, without case, spelled in the window as the model
323/// spells it; the range as written (`A1:F20`, `b2`, corners in any order),
324/// or the sheet's whole extent when none is given. A sheet the model does
325/// not hold is refused naming the ones it does (`view-sheet-unknown`); a
326/// text that is not a rectangle of cells, a whole column or row included,
327/// is refused as written (`view-window-not-a-range`).
328pub fn window_of(model: &Workbook, sheet: &str, range: Option<&str>) -> Result<Window, Refusal> {
329    let Some(i) = model.find_sheet(sheet) else {
330        let sheets = model
331            .sheets
332            .iter()
333            .map(|s| s.name.as_str())
334            .collect::<Vec<_>>()
335            .join(", ");
336        return Err(raise(
337            "view-sheet-unknown",
338            &[("name", sheet), ("sheets", &sheets)],
339        ));
340    };
341    let s = &model.sheets[i];
342    let range = match range {
343        Some(text) => parse_a1_range(text)
344            .ok_or_else(|| raise("view-window-not-a-range", &[("text", text)]))?,
345        None => whole(s),
346    };
347    Ok(Window {
348        sheet: s.name.clone(),
349        range,
350    })
351}
352
353/// The view record of one window as text, each line ending in a line feed:
354/// the API's surface, the tests' and a door's that holds the text.
355pub fn view_text(model: &Workbook, window: &Window) -> String {
356    let mut out = String::new();
357    Grid::default().project(model, window, &mut |line| {
358        out.push_str(line);
359        out.push('\n');
360    });
361    out
362}
363
364/// The view record with each formula's computed value after it: the record
365/// a door prints once it has recalculated the model (`frazaro view` through
366/// the core's Excel library; an engine after a step). `values` must be over
367/// `model`.
368pub fn view_text_valued(model: &Workbook, window: &Window, values: &dyn Values) -> String {
369    let mut out = String::new();
370    Grid::valued(values).project(model, window, &mut |line| {
371        out.push_str(line);
372        out.push('\n');
373    });
374    out
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use crate::sheet::{Rgb, Styles};
381
382    fn range(text: &str) -> A1Range {
383        parse_a1_range(text).unwrap()
384    }
385
386    fn lines(text: &str) -> Vec<&str> {
387        text.lines().collect()
388    }
389
390    /// A small model made by hand: two sheets, the first with columns and
391    /// formats, a shared formula, a dynamic one, a truth value, a number no
392    /// cell can hold, and the rows of the sentences that wrote them.
393    fn a_model() -> Workbook {
394        let mut wb = Workbook::new();
395        let mut styles = Styles::new();
396        let lavender = styles.id(Style {
397            fill: Some(Rgb(247, 244, 252)),
398            num_fmt: NumFmt::Text,
399            wrap: false,
400        });
401        let gray = styles.id(Style {
402            fill: Some(Rgb(242, 242, 242)),
403            num_fmt: NumFmt::General,
404            wrap: true,
405        });
406        wb.styles = styles;
407        let mut model = Sheet::new("Model");
408        model.gridlines = false;
409        model.columns = vec![
410            Column {
411                index: 3,
412                width: Some(60.0),
413                hidden: false,
414                style: Some(gray),
415            },
416            Column {
417                index: 1,
418                width: None,
419                hidden: true,
420                style: None,
421            },
422            Column {
423                index: 2,
424                width: Some(72.0),
425                hidden: false,
426                style: Some(lavender),
427            },
428        ];
429        model.set(
430            1,
431            2,
432            Cell {
433                content: Content::Text("Revenue".to_string()),
434                style: lavender,
435            },
436        );
437        model.set(
438            2,
439            2,
440            Cell {
441                content: Content::Number(1200.5),
442                style: 0,
443            },
444        );
445        model.set(
446            3,
447            2,
448            Cell {
449                content: Content::Bool(true),
450                style: 0,
451            },
452        );
453        model.set(
454            4,
455            2,
456            Cell {
457                content: Content::Number(f64::INFINITY),
458                style: 0,
459            },
460        );
461        model.set_formula(range("C2:C4"), "B2*2", 0);
462        model.set_formula_dynamic(range("D2"), "_xlfn.IFS(B2>3,\"big\",TRUE,\"small\")", 0);
463        model.wrote(range("B1"), 1);
464        model.wrote(range("B2:B4"), 2);
465        model.wrote(range("C2:C4"), 3);
466        model.wrote(range("D2"), 4);
467        wb.sheets.push(model);
468        wb.sheets.push(Sheet::new("Empty"));
469        wb
470    }
471
472    #[test]
473    fn the_record_s_rows_have_the_fixed_spelling_in_the_fixed_order() {
474        let wb = a_model();
475        let text = view_text(
476            &wb,
477            &Window {
478                sheet: "Model".to_string(),
479                range: range("A1:F20"),
480            },
481        );
482        assert_eq!(
483            lines(&text),
484            vec![
485                "(sheet \"Model\" visible)",
486                "(sheet \"Empty\" visible)",
487                "(window \"Model\" \"A1:F20\")",
488                "(extent \"Model\" \"B1:D4\")",
489                "(gridlines \"Model\" off)",
490                "(column \"Model\" \"A\" none hidden none)",
491                "(column \"Model\" \"B\" 72 shown 1)",
492                "(column \"Model\" \"C\" 60 shown 2)",
493                "(format 0 none general nowrap)",
494                "(format 1 \"F7F4FC\" text nowrap)",
495                "(format 2 \"F2F2F2\" general wrap)",
496                "(cell \"Model\" \"B1\" \"Revenue\")",
497                "(style \"Model\" \"B1\" 1)",
498                "(sentence \"Model\" \"B1\" 1)",
499                "(cell \"Model\" \"B2\" 1200.5)",
500                "(sentence \"Model\" \"B2\" 2)",
501                "(formula \"Model\" \"C2\" \"=B2*2\")",
502                "(sentence \"Model\" \"C2\" 3)",
503                "(formula \"Model\" \"D2\" \"=IFS(B2>3,\\\"big\\\",TRUE,\\\"small\\\")\")",
504                "(sentence \"Model\" \"D2\" 4)",
505                "(cell \"Model\" \"B3\" true)",
506                "(sentence \"Model\" \"B3\" 2)",
507                "(formula \"Model\" \"C3\" \"=B3*2\")",
508                "(sentence \"Model\" \"C3\" 3)",
509                "(cell \"Model\" \"B4\" (error \"#NUM!\"))",
510                "(sentence \"Model\" \"B4\" 2)",
511                "(formula \"Model\" \"C4\" \"=B4*2\")",
512                "(sentence \"Model\" \"C4\" 3)",
513            ]
514        );
515    }
516
517    #[test]
518    fn a_window_clips_to_its_rectangle_and_an_empty_one_prints_its_header_alone() {
519        let wb = a_model();
520        // The clip: the shared formula's children keep their moved text, and
521        // only column C's settings are in the window.
522        let text = view_text(
523            &wb,
524            &Window {
525                sheet: "Model".to_string(),
526                range: range("C3:D4"),
527            },
528        );
529        assert_eq!(
530            lines(&text),
531            vec![
532                "(sheet \"Model\" visible)",
533                "(sheet \"Empty\" visible)",
534                "(window \"Model\" \"C3:D4\")",
535                "(extent \"Model\" \"B1:D4\")",
536                "(gridlines \"Model\" off)",
537                "(column \"Model\" \"C\" 60 shown 2)",
538                "(format 0 none general nowrap)",
539                "(format 2 \"F2F2F2\" general wrap)",
540                "(formula \"Model\" \"C3\" \"=B3*2\")",
541                "(sentence \"Model\" \"C3\" 3)",
542                "(formula \"Model\" \"C4\" \"=B4*2\")",
543                "(sentence \"Model\" \"C4\" 3)",
544            ]
545        );
546        // Past the extent: the header says where the cells are, and no cell row follows.
547        let text = view_text(
548            &wb,
549            &Window {
550                sheet: "Model".to_string(),
551                range: range("H10:J12"),
552            },
553        );
554        assert_eq!(
555            lines(&text)[2..],
556            [
557                "(window \"Model\" \"H10:J12\")",
558                "(extent \"Model\" \"B1:D4\")",
559                "(gridlines \"Model\" off)",
560                "(format 0 none general nowrap)",
561            ]
562        );
563        // An empty sheet: no extent, gridlines on, the default format.
564        let text = view_text(
565            &wb,
566            &Window {
567                sheet: "Empty".to_string(),
568                range: whole(&wb.sheets[1]),
569            },
570        );
571        assert_eq!(
572            lines(&text)[2..],
573            [
574                "(window \"Empty\" \"A1\")",
575                "(extent \"Empty\" none)",
576                "(gridlines \"Empty\" on)",
577                "(format 0 none general nowrap)",
578            ]
579        );
580        // A window onto no sheet, which window_of never makes: the sheets,
581        // the window, and nothing else.
582        let text = view_text(
583            &wb,
584            &Window {
585                sheet: "Nowhere".to_string(),
586                range: range("A1"),
587            },
588        );
589        assert_eq!(lines(&text).len(), 3);
590        assert_eq!(lines(&text)[2], "(window \"Nowhere\" \"A1\")");
591    }
592
593    #[test]
594    fn row_settings_looks_and_hidden_sheets_print_in_their_places() {
595        // KERNEL.22: the `row` rows after the `column` rows, the `look` rows
596        // after the `format` rows with their formats among them, and each
597        // sheet's own visibility.
598        let mut wb = a_model();
599        wb.sheets[1].visibility = crate::rows::Visibility::Hidden;
600        let model = &mut wb.sheets[0];
601        model.rows.insert(
602            3,
603            crate::sheet::RowSettings {
604                height: Some(30.0),
605                hidden: false,
606            },
607        );
608        model.rows.insert(
609            9,
610            crate::sheet::RowSettings {
611                height: None,
612                hidden: true,
613            },
614        );
615        model.looks.push(("1".to_string(), 2));
616        let text = view_text(
617            &wb,
618            &Window {
619                sheet: "Model".to_string(),
620                range: range("B2:B4"),
621            },
622        );
623        assert_eq!(
624            lines(&text)[..10],
625            [
626                "(sheet \"Model\" visible)",
627                "(sheet \"Empty\" hidden)",
628                "(window \"Model\" \"B2:B4\")",
629                "(extent \"Model\" \"B1:D4\")",
630                "(gridlines \"Model\" off)",
631                "(column \"Model\" \"B\" 72 shown 1)",
632                "(row \"Model\" 3 30 shown)",
633                "(format 0 none general nowrap)",
634                "(format 1 \"F7F4FC\" text nowrap)",
635                "(format 2 \"F2F2F2\" general wrap)",
636            ]
637        );
638        assert_eq!(lines(&text)[10], "(look \"Model\" 1 2)");
639        assert!(
640            !text.contains("(row \"Model\" 9"),
641            "row 9 is outside the window"
642        );
643    }
644
645    #[test]
646    fn window_of_finds_the_sheet_without_case_and_refuses_the_rest_by_name() {
647        let wb = a_model();
648        let w = window_of(&wb, "MODEL", None).unwrap();
649        assert_eq!(w.sheet, "Model");
650        assert_eq!(w.range, range("B1:D4"));
651        let w = window_of(&wb, "empty", None).unwrap();
652        assert_eq!((w.sheet.as_str(), w.range.text().as_str()), ("Empty", "A1"));
653        let w = window_of(&wb, "Model", Some("f20:a1")).unwrap();
654        assert_eq!(w.range.text(), "A1:F20");
655        let r = window_of(&wb, "Summary", None).unwrap_err();
656        assert_eq!(
657            (r.id.as_str(), r.source.as_str()),
658            ("view-sheet-unknown", "VLA-View")
659        );
660        assert!(r.text.contains("Summary"), "{}", r.text);
661        assert!(r.text.contains("Model, Empty"), "{}", r.text);
662        for text in ["A:A", "3:3", "nonsense", "", "A1:B"] {
663            let r = window_of(&wb, "Model", Some(text)).unwrap_err();
664            assert_eq!(r.id, "view-window-not-a-range", "{text}");
665            assert!(r.text.contains(text), "{}", r.text);
666        }
667        assert_eq!(Grid::default().name(), "grid");
668    }
669}