Files
gridcraft/docs/architecture.md
T
Pranav e1a12fa337 UI: theme tokens with a contrast test, ribbon overflow arrows and split button, multi-line formula bar, code editor widget (#456)
* UI: theme tokens for icon, reference and sheet-control colours; WCAG contrast test

Tokens gains the colours the chrome was hard-coding, each with a light
and a dark value: on_accent (text on an accent fill), an eight-colour
category palette (theme::Cat), the paper colour inside icons, reference
colours for the formula bar and for the sheet, the message box warning
disc, the tab bar scroll thumb, and sheet_control_* colours that follow
the sheet and so stay light in both themes.

What now comes from the tokens:
- icons: paper (white pages glared in dark mode) and the accent
  constants; icons placed on the sheet use icons::paint_on_sheet, which
  keeps the light colours;
- reference colours (editor::REF_COLORS), which were hard to read on the
  dark formula field;
- white text on the accent in OK, Solve, slicer tiles and the AutoSave
  switch, 2:1 on the dark accent;
- the comments pane (a white card with light text in dark mode), gallery
  hover and swatch outlines, the message box disc, the tab bar thumb,
  chart selection frames, and the sheet's filter and validation buttons.

A unit test (theme::contrast_tests) checks every token pair used by the
ribbon, popups, dialogs, panes, formula bar, status bar and sheet in
both themes against WCAG 2: 4.5:1 for text and 3:1 for glyphs. It caught
the dark danger red (3.5:1 on hover) and the selected header text (4.1:1
light), which are adjusted.

* UI: ribbon scroll arrows, large split button for Formulas › AutoSum

- widgets::big_split_button: the icon runs the default action, the
  label and its arrow open the menu. It returns both responses, with
  accessible names ("AutoSum" and "AutoSum options"). big_button shares
  its label layout.
- Formulas › AutoSum uses it: the icon inserts SUM as before, and the
  arrow, which used to do the same, opens the Sum / Average / Count /
  Max / Min / More Functions menu that Home › AutoSum already had
  (shared as autosum_menu). Its keytip is unchanged.
- When a tab's groups are wider than the window, arrows at the card's
  edges scroll them; before, only the mouse wheel did and nothing showed
  that the tab went on. ribbon::RibbonOverflow records the groups' and
  the card's sizes each frame.
- A test checks every tab fits the card's three rows. It found View ›
  Show stacking four checkboxes, with Headings cut off; Show is now two
  columns (Ruler, Gridlines / Formula Bar, Headings).

Tests: tests/ribbon_layout.rs (the split button's two parts, AutoSum's
icon and menu, the scroll arrows, the card height).

* UI: formula bar grows from one to five lines; code editor widget

The formula bar is a top-aligned row: the Name Box and the cancel,
enter and fx buttons stay on the first line while the field grows. Its
height is the content's laid-out line count (Alt+Enter or wrapped text)
times the font's row height, from one line (30 px, as before) to five,
then the field scrolls with the caret in view. This holds while editing
and while viewing a cell. The height eases over 120 ms when the line
count changes, and the chevron pins it at five lines (it used to give a
fixed 72 px). The in-cell editor likewise stops at five lines and
scrolls. formula_bar::BarLayout records where the parts went, for tests.

widgets::code_editor(ui, id, &mut text, Lang, max_lines) is a small
code editor for features that edit code: the monospace font, syntax
colours from Tokens::syntax (new, with light and dark values in the
contrast test) for Python (keywords, strings including triple-quoted
ones, numbers, comments and builtins) and LaTeX (commands, scripts and
comments), Tab inserting four spaces (or indenting every selected line,
Shift+Tab dedenting), Enter keeping the line's indentation (one level
more after a Python colon), and a scroll area that follows the caret.
The tokenizer and the edits are pure functions with tests, including
hostile input that must keep spans on char boundaries.

kittest (tests/formula_bar.rs) covers Alt+Enter up to eight lines with
the Name Box and buttons fixed and the caret visible, the commit,
viewing the committed cell, the chevron, the ease, the in-cell cap and
the code editor's Tab and Enter.

* UI: Shift+Tab in the code editor dedents instead of indenting

The editor consumed plain Tab before Shift+Tab, and egui's key match ignores Shift
when the pattern lacks it, so a Shift+Tab was taken as Tab and indented the line.
Shift+Tab is now consumed first; a plain Tab still inserts spaces.

* UI: the ribbon card stays inside a narrow window, so its right scroll arrow is on screen

The row above the ribbon can be wider than a narrow window, and the card took that width, so the
right scroll arrow was drawn past the window's edge and couldn't be clicked: below about 640 px on
macOS, and at 700 px with Linux's wider default fonts, where CI's ribbon test failed. The card is
now kept within the window with the same margin it has on the left.

* UI: each ribbon tab keeps a scroll offset of its own

The ribbon's scroll area used one identity for every tab, so scrolling an overflowing tab and
switching to another overflowing one hid that tab's first groups. Each tab now has its own scroll
state: a tab opened after another was scrolled starts at its left edge, and a tab keeps its offset
while others are used.

* UI: the in-cell editor keeps its whole width for the text and shows every line

Merged with main, the grid's own test asked the in-cell editor to wrap its
text at the field's full width and to show every line (the editor never
scrolls); the editor widget wrapped it inside the formula bar's scroll area,
which took the bar's width off the text (168 px tall where 156 was right),
and the grid capped it at five lines. The in-cell editor now shows the text
field on its own, through the one show_edit helper the formula bar also
uses, and grows with every line; the formula bar keeps its five lines and
its caret scroll. The branch's five-line test for the cell editor becomes a
grows-with-every-line test.

* Docs: the UI building blocks' revision rows carry the date they land

The rows said 2026-10-11, older than upstream rows above them, and the
ui-parity row said the in-cell editor is capped at five lines.

* UI: the formula bar's view of a cell starts at the cell's top

The bar's read-only view kept one scroll offset for every cell, so a cell
shown after scrolling another's text started scrolled. The offset is kept
per cell now.

* UI: the editor's field does nothing with Enter on its own

The editor handles Enter (commit) and Alt+Enter (a line break) itself, and a
message box over the editor takes Enter to close. The field under it is a
multi-line TextEdit since the bar grew, whose own return key would add a
line if it ever saw Enter; return_key(None) keeps that from depending on
where focus sits. A test shows Enter over a message box adds nothing to the
entry (focus is off the field then, so it passed before too: this is
hardening, not a repair). The doc comment split by the show_edit helper is
mended.

* Docs: ui-parity says the in-cell editor shows every line, and its revision table is whole

A sentence still said the in-cell editor stops at five lines, and a blank
line between the table's separator and its first row ended the table.

* UI: the split button's tooltip carries the shortcut hint like every ribbon button's

big_split_button built its tooltip with the bare translation, so AutoSum,
which became a split button, lost its shortcut hint. It uses tip_text now;
the split-button test hovers the button and looks for the hint.

* UI: a selection that ends at the start of a line leaves that line alone

Selecting "a\n" in "a\nb" and pressing Tab also indented b, and Shift+Tab
could take its indentation: the line after the selection was counted as
touched. It is touched only when the selection reaches into it.

* UI: the code editor applies its keys in input order, with the typing around them

The editor consumed Enter, Tab and Shift+Tab before the text field saw the
frame's events, so a frame carrying typed text followed by Enter (events
gather between repaints, and under key repeat) put the newline before the
text, and two Enters in one frame made one line. The editor now walks the
frame's events in order and applies typed text, pastes, Enter, Tab,
Shift+Tab, Backspace and Delete itself, taking them from the queue; the
first event it does not handle and everything after it stay for the field,
which runs next, so the order holds either way.

* UI: the code editor keeps the input order past the keys the text field handles

The editor applied its keys up to the first event it did not handle and
left that event and everything after it to the text field, which has
its own Tab and Enter: select all followed by Tab in one frame replaced
the code with a tab character, and since the platform sends a key event
before each text event, an Enter after ordinary typing lost its
indentation.

The editor now applies its keys up to the first event the field acts on
(a caret or selection key, a key under a command modifier, IME, a click);
that event and the run after it stay for the field; from the editor's
next key on, the keyboard events are held back under the editor's id for
its next pass, where it puts them ahead of that pass's input and splits
again. They go only to the editor they were typed into, only while it
still has the focus, and only in the pass right after; otherwise they
are dropped rather than typed into another widget. Printable key presses
do not split the stream. Tab under a command or option modifier is taken
off the queue, and Enter under Shift or Option is the editor's newline,
so the field never gets a Tab or an Enter of a focused editor.

* UI: the ribbon layout and formula bar tests join the crate's integration test binary

Since #2372 the crate's integration tests are one binary built from tests/it/, one module per file; only tests that set process-wide state stay separate. These two set none.

* UI: the formula bar's one-line height follows the font, so five lines fit on Windows

One line was a fixed 30 px and the bar grew by one row height per line.
The panel's and the field's margins take 12 px of that, and rounding to
the pixel grid up to another, so a row taller than 17 px lost the last
counted line: with the Windows UI font (17.28 px rows) five lines showed
four, and bar_grows_to_five_lines_then_scrolls_with_name_box_pinned
failed there.

formula_bar::one_line(row_h) is 30 px unless the rows need more, and
every counted line fits whatever the font. The kittest heights are
computed from it rather than from 30, and a unit test checks the
arithmetic for rows of 15.6 to 20 px.

* UI: Paste Special's OK button uses the on-accent colour

It was the one OK button still drawn white on the accent: 2.14:1 in the dark theme, where every other OK uses on_accent (7.1:1).
2026-10-24 20:54:34 -06:00

12 KiB
Raw Blame History

Architecture

Last reviewed: 2026-10-10 · ** Last updated:** 2026-10-24 · Change: minor (engine building blocks for multi-command actions) · Target: Microsoft Excel (Microsoft 365)

How GridCraft is built today. Rules for contributors are in AGENTS.md.

Workspace and layering

An engine-first Cargo workspace. Layering is enforced by cargo xtask layers; nothing below L6 depends on egui, eframe, winit or rfd, so the UI crate is swappable. Line counts are Rust lines including tests, 2026-10-10.

Layer Crate Lines Job
L0 core ~1.3k Cell and range addresses (A1, R1C1, sheet-qualified; 1,048,576 × 16,384), Value, CellError, Excel coercion, 1900/1904 date serials, typed-input parsing
L0 numfmt ~2.1k Excel number-format codes (sections, conditions, colours, dates, elapsed time, fractions, scientific, fill), General, TEXT(); en-US only
L1 formula ~1.7k Tokenizer, parser, AST, printer (A1 and R1C1), reference adjustment for copy, fill and structural edits
L1 functions ~13.4k The function library: FnSpec registry (name, arity, category, scalar args, signature, description) and pure implementations; automatic array lifting
L2 model ~2.2k Workbook, sheets, copy-on-write cell store (64-row bands behind Arc), interned styles, names, tables, CF, validation, comments, charts, shapes, images, sparklines, print settings, protection
L2 calc ~2.9k Evaluator (references, names, tables, LET/LAMBDA, special forms like OFFSET/INDIRECT/SUBTOTAL) and dependency-graph recalculation with dynamic arrays, spills and cycle detection; single-threaded
L2 xlsx ~10.7k XLSX read/write (transitional and strict read), CSV/TSV, XLSB and ODS data import, PivotTable caches, DrawingML charts and drawings; byte slices only (WASM-safe); lenient reader that reports warnings
L2 pdf ~1k Small PDF 1.7 writer (14 standard fonts, vectors, images) for print and export
L3 chart ~2.9k Toolkit-free chart layout to drawing primitives, a rasterizer, sparklines
L5 engine ~17.7k Session: open workbooks, selection, history (COW snapshots), clipboard, fill, sort, filter, PivotTables, what-if, print, file I/O (io.rs), and the command registry (cmd/*, 313 commands) with the Excel ribbon catalog (catalog.rs)
L6 ui-egui ~11.6k Excel-style UI (title bar, QAT, ribbon, formula bar, virtualised grid, editor, tabs, status bar, dialogs, task panes, chart view) and the control channel
L6 mcp ~1.4k MCP server over stdio (hand-written JSON-RPC), headless or bridged to a running app
L7 apps/gridcraft, apps/gridcraft-cli, apps/gridcraft-web ~2.2k Desktop (eframe/wgpu), CLI (info, convert, eval, cat, run, commands, functions, mcp, send), web (WASM via trunk)
— xtask — ci, parity, assets, layers, wasm, version, stats

Data flow

  1. Every action is a command. The UI, CLI, control channel and MCP call Session::execute(id, params). Commands are CommandSpec { id, label, menu, shortcut, params, enabled, run, journal, undoable }; ids follow Excel's ribbon (home.bold, insert.chart, data.sortAscending) plus primitives (cell.set, selection.set, range.setValues). Session::execute catches escaped panics and restores the workbook, its selection and its undo and redo history as they were before the command (never-crash guard); a group that was open before the command and that the command ended or rolled back stays ended and keeps a step from its start to the restored workbook, so Undo still returns to the state before it.
  2. Edits change the model through copy-on-write bands; undo keeps the previous snapshot. Undo retains at most 100 steps and trims older steps when its estimated retained cell storage exceeds 128 MiB. The newest step remains undoable even if that step alone exceeds the budget. The estimate is a history policy, not a measure or hard cap of process memory.
  3. Recalc marks dependents of changed cells dirty through the dependency graph (with range nodes for range dependents), evaluates in topological order, spills dynamic arrays and reports #SPILL!/cycles. Volatile functions are always dirty.
  4. Rendering: the egui grid reads display values (number formats applied in engine/display.rs) for the visible window only; charts render through crates/chart primitives.
  5. Files: engine/io.rs sniffs content (XLSX, XLSB, ODS) before trusting the extension and dispatches to crates/xlsx; imports that lose data return warnings shown to the user.

UI building blocks

The panels in crates/ui-egui share a few pieces, so a new feature reuses them instead of drawing its own:

  • Colours: theme::Tokens, each with a light and a dark value. The sheet stays white in both themes, so colours drawn on it (sheet_ref_colors, sheet_control_*, icons through icons::paint_on_sheet) keep their light values. theme::contrast_tests holds every foreground/background pair to WCAG 2 (4.5:1 for text, 3:1 for glyphs) in both themes; a new token pair is added there. Icons (icons.rs, drawn in code) take their paper and category colours (theme::Cat) from the tokens.
  • Ribbon widgets: widgets.rs has the buttons every tab is built from (big_button, small_button, split_button, big_split_button, toggle_button), translated through i18n in one place. Split buttons return the main and arrow responses separately, with accessible names (AutoSum, AutoSum options). The ribbon card scrolls a tab wider than the window, with arrows at its edges; ribbon::RibbonOverflow records the groups' size each frame, and a test keeps every tab within the card's three rows.
  • Code editor: widgets::code_editor(ui, id, &mut text, Lang, max_lines) (code_editor.rs) edits code in the monospace font with Tokens::syntax colours: Python (keywords, strings including triple-quoted ones, numbers, comments, builtins) and LaTeX (commands, scripts, comments). Tab indents (every selected line, Shift+Tab dedents), Enter keeps the line's indentation (one level more after a Python colon), both applied in input order with the typing around them: a pass can carry several events, and the editor's keys that follow an event the text field acts on (select all, an arrow) wait for the editor's next pass, which puts them ahead of its input, so the field never gets a Tab or an Enter of a focused editor; and it scrolls past max_lines with the caret in view. The tokenizer and the key edits are pure functions with tests, including hostile input.
  • Formula bar: grows with its content from one line to formula_bar::MAX_LINES (five), then scrolls; one line is Excel's 30 px unless the font's rows need more (formula_bar::one_line; the Windows UI font's row is 17.28 px), so every counted line fits whatever the font; formula_bar::BarLayout records where its parts went each frame, for tests.

Engine building blocks for multi-command actions

Actions made of many commands (macros, a debugger that runs one step per call) use these parts of Session and the engine:

  • Undo groups. Session::grouped(label, f) runs f as one undo step and rolls every change back if f fails; group_begin, group_end and group_rollback do the same across calls. While a group is open on a document, its commands add no undo steps of their own and Undo and Redo are disabled there. Groups nest; the outermost one makes the step. A command that opens and closes its own groups still makes one undo step for itself, covering its edits inside and outside the groups. A command that ends a group open before it leaves the group its step and makes one of its own for what it changes after the end; a group a command opens and leaves open starts where the command did, but only when the command succeeds: a command that fails rolls back every group it opened, as group_rollback does. Session::abort_groups(doc), and the command automate.stopAction on the active workbook, roll back the groups left open on a document (a debugger the person abandons, a caller that dropped its token), so Undo works there again. The history limit is applied when the outermost command finishes; until then the steps made inside it are bounded on their own.
  • Recording buffer (record.rs). Session::start_recording collects each successful top-level command with the document, sheet and selection it ran in, without secret parameters. Commands run by other commands are not recorded, nor is a menu-style call that only opened a dialog (the dialog's confirming call is the step), and the buffer is independent of the journal (Automate › Record Actions still records through the journal).
  • Document trust. DocState::trust is Local for workbooks made in the session and FromFile for workbooks opened from a file. Trust has no default: DocState::new takes it, so every way of making a document says where its content came from. It is also FromFile for workbooks that received a paste from one that writes formulas or validation: Paste, Paste Special › All, All Except Borders, Formulas, Formulas and Number Formats or Validation, and any linked paste other than column widths or notes. Values, formats, column widths and notes carry nothing active. Active content in a FromFile workbook asks before it runs. This is provenance, which no decision changes; DocState::scripts_trusted is the separate per-session consent for stored automate.* scripts (automate.trustScripts).
  • Workbook data in hidden names (cmd/stash.rs). An item is split over hidden workbook-scope names _gc_<key>_0, _gc_<key>_1, … of string literals within Excel's 255- and 8,192-character limits, so it survives an xlsx round trip. Characters XML can't carry (controls other than tab, line feed and carriage return, U+FFFE, U+FFFF) are stored as the noncharacter U+FDD0 and four hex digits, as is U+FDD0 itself, and read back as they were. stash::items and stash::keys list the items under a key prefix in one pass over the names, at most 10,000 of them (stash::MAX_ITEMS). The older single-name _sc_<key> items are read as well. formulas.defineName and formulas.deleteName refuse a name the workbook holds such an item under (a hidden workbook-scope name of either shape; a visible one is the person's own), and formulas.createFromSelection skips such labels, unless force: true is passed.

Agent control

  • Desktop control channel: gridcraft --control 7979, JSON lines on localhost (engine.execute, ui.screenshot, pointer and keyboard input…), see control-protocol.md.
  • MCP: gridcraft-cli mcp, headless or --connect to a running app, see mcp.md.
  • CLI: cli.md.

Platforms and builds

macOS universal, Windows x64/x86/arm64, Linux x86_64/aarch64/riscv64 (AppImage, Flatpak, deb, rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in .github/workflows/release.yml; see releasing.md.

Known architectural gaps

  • No threading in calc (see hardware-parity.md).
  • No string catalog or text shaping (see localization-parity.md).
  • XLSX reader builds the whole workbook in memory; no streaming (#175).
  • Unmodelled XLSX parts are not carried through a save.

Revision history

Date Change Summary
2026-10-24 minor Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage
2026-10-14 minor Document Undo's retained-cell-storage budget and latest-step guarantee
2026-10-10 major Created from the code on main: crates, layers, data flow, agent control, known gaps