Files
gridcraft/docs/control-protocol.md
T
Pranav 4e43ad8868 UI: RefEdit follow-ups: a dialog keeps the sheet it was opened on, a click on the selected cell picks, quoted sheet names in Solver (#3180)
* UI: a dialog's reference boxes belong to the sheet it was opened on, recorded when it opens rather than at the first pick

* UI: a click on the cell already selected writes it into the reference box being picked into

* Solver: references split into areas only at commas outside quoted sheet names, through one splitter shared with the reference boxes

* Solver: a constraint splits at its relation only outside quoted sheet names, in constraint text and in a model loaded from cells

* Solver: Save Model counts a constraint's areas outside quoted sheet names, and reads a number on the right side as one value whatever its separators

* UI: a Ctrl+click that follows a link is no pick of the cell already selected

The press on the grid tells the reference box being picked into that a
cell was selected only in the branches that select: Shift+click, Ctrl+click
that adds a cell, and a plain click. A Ctrl+click on a link's text follows
the link and selects nothing, so a link back to the selected cell no longer
replaces the box's text.

* UI: while a reference box picks, a second quick click on a cell picks it

A click on the same cell within the double-click interval started editing
the cell and skipped the pick, also when the earlier click came before the
box was clicked into. While a reference box is the one grid selections
fill, the double-click handling is skipped and the click picks.

* Docs: reference boxes take a second quick click as a pick; a followed link is none

* UI: while a reference box picks, a click on a link or checkbox picks its cell

A link was followed (Go To moved the selection into the box, and a web
link's question replaced the dialog) and a checkbox was toggled. While a
reference box is the one grid selections fill, a click only picks the cell
clicked, with or without Ctrl. The test of the Ctrl+click on a link back to
the selected cell becomes part of a test of links on the same sheet, a web
link and a checkbox.
2026-11-05 13:20:51 +01:00

16 KiB
Raw Blame History

GridCraft control protocol

Start the desktop app with a control port:

gridcraft --control 7979            # or GRIDCRAFT_CONTROL_PORT=7979

Authentication

The control port is reachable by every program on the machine, and by a web page that POSTs text/plain to 127.0.0.1:7979, so every connection must prove it knows the app's token (#777). There is no way to turn this off.

  • The app makes a token at start (256 random bits from the operating system, kept in memory) and writes it to a file only you can read: $XDG_RUNTIME_DIR/gridcraft/control-<port>.token (else ~/.cache/gridcraft/…; Windows: %USERPROFILE%\.gridcraft\…; the folder is mode 0700 and the file 0600 on Unix). The path is printed on stderr. The token itself is never printed or logged.
  • Or choose the token yourself: GRIDCRAFT_CONTROL_TOKEN=… (preferred) or --control-token … (visible in the process list), at least 16 characters. The app then writes no file.
  • The first line of a connection must be {"id":0,"method":"auth","params":{"token":"…"}}; the reply is {"id":0,"ok":true,"result":{"authenticated":true}}. Any other first line (a wrong or missing token, another method, text that isn't JSON such as an HTTP request, no line within 10 s, or one longer than 4 KiB) gets {"ok":false,"error":"authentication required"} and the connection is closed. At most 16 connections may be waiting to send their token at once; past that a new connection is closed before anything is read. After that, a line that isn't JSON also closes the connection.
  • gridcraft-cli send/mcp --connect and gridcraft_mcp::Remote do this themselves: they use GRIDCRAFT_CONTROL_TOKEN if set, else the token file of that port (so they work for the same user on the same machine without setup). Your own client reads the file or the variable.

Requests

Then send newline-delimited JSON to 127.0.0.1:7979 (after the auth line). Every request line gets one reply line:

{"id": 1, "method": "engine.execute", "params": {"command": "cell.set", "params": {"cell": "B2", "input": "=SUM(A1:A9)"}}}
{"id": 1, "ok": true, "result": {"cell": "B2", "value": "45"}}

Errors come back as {"id": …, "ok": false, "error": "…"}. Asynchronous work that finishes later than the reply (a ui.screenshot capture) adds its own request token to the reply so the client can match it; a capture that completes after its 8 s deadline returns the timeout error and writes no file.

A line that is not a JSON object with a string method (an HTTP request, text that isn't JSON, bytes that aren't UTF-8, a line over 64 MiB) gets one error reply and the server closes the connection; nothing sent after it runs. Blank lines are skipped.

Methods

Method Params What it does
engine.execute {command, params, window?, doc?} Runs any engine command (see engine.commands or gridcraft-cli commands) in the active window, or in the window named by window (a window id) or doc (a workbook id: its most recent window). The active window stays in front afterwards unless the command itself switches windows. edit.goTo, edit.goToSpecial, selection.set and selection.move scroll the active cell into view, as Go To does in the window
window.list The open windows: [{window, doc, index, title, dirty, active, hidden}]. title is the workbook name, or "Book1:1", "Book1:2"… when a workbook has several windows; index is the workbook's position among the open workbooks
engine.commands {search?} Every command: id, label, ribbon path, shortcut, params doc, enabled; search keeps those whose id, label or ribbon path contains it (case-insensitive)
engine.journal Commands run so far (replayable). Passwords (password, …Password params) are left out, so a replayed Protect Sheet has no password. Capped at 10,000 entries and 64 MB (serialized); past either cap the oldest entries go
document.inspect Workbook summary: sheets, used ranges, tables, charts, names, selection, undo labels
sheet.read {range?, sheet?, formulas?, formatted?} Values (or formulas / displayed text) of a range
cell.get {cell?} One cell: value, formula, displayed text, style, merge, comment, link
ui.inspect UI state: ribbon tab, editor (python is true in Python mode; a draft in Python mode or of a Python cell's code has text null (a draft typed over a Python cell holds only what was typed and is shown) and hidden true while that cell's code is hidden on a protected sheet, or once the draft's sheet is gone; the sheet is found by its identity, whatever was inserted, deleted or moved before it), open dialog, Backstage page, message, selected object, grid rect, scroll, perf, windows (as window.list) and activeWindow
ui.object.select {id?, kind?} Selects a floating object on the active sheet and replies {selected, rect}, the rect in window points; with no id it clears the selection and replies null
ui.click / ui.drag / ui.move {x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?} Real pointer input in window points; refused with an error if it would leave more than 10,000 synthetic events or 8 MiB of synthetic input queued (pointer, key, text and move events share this budget)
ui.cell.click / ui.cell.drag {cell} / {from, to} Pointer input at a cell's centre (works in point mode while editing); same shared queue limits
ui.key / ui.text {key, shift?, cmd?, alt?, ctrl?} / {text} Keyboard input (typing in the grid starts editing); same shared queue limits, and one ui.text string may be at most 1 MiB
ui.edit.begin / ui.edit.text / ui.edit.commit / ui.edit.cancel {text?} / {move?: down|right|up|left|none} Drive the cell editor
ui.ribbon {tab} Switch ribbon tab
ui.dialog / ui.dialog.set / ui.dialog.confirm / ui.dialog.cancel {name} / {field, value} Open, fill in and confirm dialogs; confirm runs the dialog's command with the same parameters as its OK button. confirm applies to a dialog whose OK runs a command (Solver, Data Analysis, the form dialogs); a dialog that presses its own OK, such as Goal Seek, answers this dialog has no OK command; drive it with its own commands and is driven with that command (data.goalSeek). That command does not go back to the dialog's sheet: after another sheet was shown, read the fields (ui.inspect reports the dialog's values), cancel the dialog (which shows its sheet again), then run data.goalSeek with those values, so references without a sheet name mean the dialog's sheet. Once the dialog's own sheet is deleted, cancel leaves the sheet shown as it is and replies {"sheetDeleted": true} (the sheet is found by identity, so a new sheet given its name does not count); the script then stops instead of running data.goalSeek, whose references without a sheet name would mean the sheet shown. A dialog with reference boxes (RefEdit) goes back to its own sheet, the one shown when it was opened (a sheet shown later, before any pick, does not take its place): cancel puts the selection from before picking back; confirm puts it back before the command runs (so references without a sheet name mean the dialog's sheet). Both fail, with the dialog left open, when a text-box draft on the picked sheet cannot be saved (its worksheet is no longer the active one), and confirm also once the dialog's sheet was deleted; the window's own Cancel closes the dialog in that case and shows the reason.
ui.pythonMode (via engine.execute) {on?} Python mode on the active cell, in the formula bar (an edit in progress keeps its text); on: false turns it off; without on it switches. A start that is refused (a locked cell on a protected sheet, a cell or draft inside another cell's spill, code hidden on a protected sheet) is an error with the reason. Opens no dialog
ui.backstage (via engine.execute) {page?: home|new|open|info|saveAs|print|export|options|account|close} Open the File tab's Backstage view on a page, or close it (ui.inspect reports the open page as backstage)
ui.screenshot {path?} PNG of the window (base64 when no path). Animations are settled first: the picture shows the final state. On the native app it works while hidden or occluded when the platform still reports the current content size (the request asks for a hidden paint); a minimized window has no capturable size, so its request stays queued until the window is restored and otherwise reports the 8 s timeout. In a hidden browser tab the web runner does not implement that paint request, so the capture waits until the tab is shown again or reports the 8 s timeout. Up to 4 outstanding requests; retained images are limited to 128 MiB each and 256 MiB in total (each request reserves the largest image the ±1-pixel rounding rule can retain), one capture's transient encoding working set to 256 MiB, a base64 reply to 64 MiB, and retained images plus one capture's encoding working set to 512 MiB; a request that would exceed a budget is refused without evicting earlier ones. See Screenshot budgets. A resize/DPI/zoom/visibility/occlusion change during capture is retried within the bounded retry budget or reported instead of saving stale pixels; the identity intentionally includes zoom and occlusion even though the expected physical size may be unchanged, so a capture never crosses layout generations. Every reply — success, refusal or timeout — carries the request token as request
ui.set {dark?, formulaBar?, ribbonCollapsed?, animations?} UI preferences (formulaBar runs view.formulaBar)
ui.resize / ui.focus {width, height} Window control
app.open / app.save / app.quit {path, password?} / {path?} Files and lifetime. password opens a password-protected workbook; without it, such a workbook returns an error (never a dialog)

Screenshot budgets

ui.screenshot keeps up to four requests outstanding. Each admitted request reserves the largest image the ±1-pixel rounding rule can retain: 128 MiB per image and 256 MiB in total across requests, and a request that would exceed a budget is refused without evicting earlier ones. Encoding is bounded separately: one capture's transient working set may be 256 MiB (the larger of the retained image plus its RGBA copy, or, for a base64 reply, the image plus the PNG stream plus the encoded text), the base64 reply itself may be 64 MiB, and retained images plus one capture's encoding working set may be 512 MiB. Base64 text counts toward the encoding working set, so a large pathless capture can be refused there or by its base64 length even when its retained image fits. path writes the PNG to a file instead of returning base64 text and so avoids those two base64 costs, but it does not bypass the per-image, aggregate, encoding-working-set or peak limits.

Any other method name that is a command id (e.g. home.bold) runs that command. Its params may include window or doc to run it in that window, as with engine.execute; the window commands (window.*, view.newWindow, view.hideWindow, view.unhideWindow, view.switchWindows) read them as their own parameters.

ui.inspect reports the selected floating object as selectedObject: {kind, id} (or null), where kind is chart, image, shape, equation, slicer or control (a form button, slider or spin button); the same names go in the kind parameter of object.delete, object.move, object.setAnchorMode and the arrange.* commands. An equation is a shape too, so its id works under shape; a plain shape's id given as equation, a kind that is present but not one of these names (null included), or an id of no object of the kind, is an error. arrange.align and arrange.group check every id in ids the same way; with fewer than two ids they change nothing. Form controls are worked with control.setValue and control.press, and listed by control.list. object.move and chart.set cap width and height at 10,000 points, or at the object's current size when a file made it larger: such an object keeps its size when it is moved and shrinks by what a resize asks, but does not grow further. selectedChart remains for existing clients: the id when the selection is a chart, otherwise null. With an object selected, ui.key {key: "Delete"} (or Backspace) deletes the object instead of clearing cells, and Escape clears the selection.

Windows

Every open workbook has one or more windows; View › New Window (view.newWindow) adds another on the same workbook, and saving from any of them saves that workbook. Each window has its own selection, active sheet and scroll position; switching windows never marks the workbook as changed. window.activate {window}, view.switchWindows {window} and view.unhideWindow {window} bring a window forward; window.close {window?, force?} closes one (the workbook closes with its last window, asking about unsaved changes unless force); file.close closes the active window, or with {document: true} the workbook and all its windows.

Use engine.execute with {command: "view.theme", params: {mode: "system"}} to choose the display theme; mode accepts "system", "light", or "dark". System follows live OS appearance changes and falls back to Light when the platform supplies no preference. The saved choice remains System; new users still start in Light. The legacy view.darkMode command and ui.set {dark} select a fixed Light or Dark theme.

{command: "view.animations", params: {on: false}} turns motion in the UI off (the selection outline then jumps to a new selection instead of gliding there); without on it toggles. It is the View ▸ Animations checkbox, saved with the other UI preferences and on by default.

{command: "app.language.set", params: {language: "ja"}} switches the interface language (code is accepted as an alias for language); app.language.english and app.language.japanese are shortcuts. engine.commands lists every code it takes (en, es, zh, ja, ko, ru, pt, de, cs, fr, sr), as does the error for an unknown one. Only interface chrome is translated, never command ids or document text. The choice is saved in ui.json; with nothing saved the desktop app follows the system language.

{command: "file.open", params: {path: "Secret.xlsx", password: "…"}} opens a password-protected workbook (Agile or Standard encryption). Without password it returns an error containing "is password-protected. Enter its password to open it." and never opens a dialog; a wrong password returns "The password is incorrect." The password is left out of the journal and never quoted in an error. The opened workbook has no save path, so Save asks where to save it, without the password, and never replaces the encrypted original.

For a headless equivalent (no window), use gridcraft-cli mcp or gridcraft-cli run; see mcp.md and cli.md.

Native printing

Desktop engine.commands {"search":"print"} (and connected MCP list_commands) lists ui.printSettings and ui.printSubmit with their parameter and result fields.

engine.execute can call ui.printSettings with {} to inspect the printer list, selected printer, copies, duplex, grayscale, discovery/submission status and backend receipt/error. {"refresh":true} starts background discovery; poll the same command until discovering is false. Set printer to a discovered name, integer copies from 1 to 999, duplex to printerDefault, off, longEdge or shortEdge, and boolean grayscale. Invalid updates are rejected without changing earlier settings. ui.printSubmit explicitly submits the current print-ready PDF without opening a dialog; inspect ui.printSettings until busy becomes false and check status/detail. The browser keeps its own print hook. PDF export remains separate. GridCraft accepts the existing file.print sheet/pagination params on ui.printSubmit; the engine file.print command returns the PDF without spooling.