* UI: RefEdit reference boxes in dialogs; Goal Seek picks its cells on the grid A reference box (RefEdit) is a text field that takes a range by selection on the grid. While a box is the one being picked into (it was clicked into or collapsed), clicking or dragging on the sheet writes the absolute reference into it, with the sheet name when the selection is on another sheet; typing still edits it, and the box takes the keyboard back after a pick. Focusing another text field of the dialog ends picking. The collapse button beside each box shrinks the dialog to that box, at the dialog's top-left corner, so the sheet is free for selecting. Escape, Enter or the expand button restore the dialog. The sheet and selection from before picking are restored before the dialog runs its command (references without a sheet name mean the sheet the dialog was opened on) and when it closes. The button is focusable, works with Space and Enter, and is named for screen readers; the icons are drawn in code. Goal Seek's Set cell and By changing cell are reference boxes. The snapshot example's --refedit key=range collapses the open dialog to a box and selects a range, to render the collapsed state. * UI: Solver and Data Analysis take their ranges with RefEdit boxes Form dialogs gain a Ref field, drawn as a reference box with its collapse button. Data Analysis uses it for the input, variable, X and Y and bin ranges, and for Output Range, where picking or typing a reference also chooses that output option. Solver Parameters uses reference boxes for the objective, the variable cells and both sides of a new constraint. * UI: Data Analysis keeps New Worksheet Ply when chosen after picking an Output Range The Output Range box chose its option on every frame while it was the box being picked into, so clicking New Worksheet Ply after using the box reverted to Output Range on the next frame. The box now chooses Output Range only when it is clicked into or its text changes (typed or picked), and choosing another output option ends picking into it, so a later selection on the grid doesn't fill it either. * UI: RefEdit boxes remember the dialog's sheet by name The sheet a dialog was opened on was kept by index, so a sheet inserted or moved while picking (the ribbon stays usable) made a range on the new sheet lose its sheet name, and closing the dialog went back to whichever sheet now had that index. The sheet is now kept by name, as New Formatting Rule's formula box does. * UI: switching sheets while picking into a RefEdit box doesn't fill it A reference on another sheet carries the sheet name, so after a click on another sheet tab the selection never matched the one last seen, and the switch alone wrote that sheet's remembered selection into the box, replacing a typed reference. The box now notes which sheet its last seen selection was on: showing another sheet only refreshes it, and a selection made on that sheet still fills the box. * UI: a RefEdit box for one reference takes only the area being selected A selection of several areas (Ctrl+click) was written into every box as a comma-joined union, which Goal Seek, Solver's objective and constraints, and Data Analysis then refused as an invalid reference. Solver's variable cells still take every area; the other boxes take the area being selected. * UI: RefEdit boxes follow the dialog's sheet when it is renamed The dialog's own sheet was kept by name only, so renaming it while a reference box picked made references on it carry the new name as if it were another sheet, and running or closing the dialog could not put the selection back. The sheet's index and the sheet count are now kept with the name: when the name disappears while the count stays the same, the sheet at that index was renamed and its new name is taken. Inserting, moving or deleting other sheets still follows the name. * UI: a deleted dialog sheet is never taken for another sheet The dialog's own sheet was taken as renamed whenever its name was gone and the sheet count matched the last frame's, so deleting it was read as a rename a frame later (or after a sheet was inserted), and the surviving sheet at that index became the dialog's sheet: unqualified references ran against it and Cancel put the old selection on it. The sheet names of the last frame are now kept in order. A rename is taken only when the list keeps its length and only the home sheet's entry changed; any other loss of the name marks the sheet as deleted for good. Once it is deleted, picks carry their sheet's name, closing leaves the selection where it is, and OK is refused with a message, since references without a sheet name would otherwise run against whichever sheet is shown. * UI: RefEdit dialogs keep their sheet until they close OK put the saved selection back and dropped the dialog's sheet with it. When the command then failed, the dialog stayed open without its sheet: a later deletion of that sheet went unnoticed and a second OK ran references without a sheet name against the sheet shown, as it also did when the person had only moved to another sheet after the failed OK. The sheet is now kept, and followed through renames and deletion, until the dialog closes; only the saved selection is put back once. A later OK or Cancel shows the dialog's sheet again (or is refused once it is deleted), and picking again on that sheet saves its selection anew. * UI: Solver's Model Range is a RefEdit box * UI: a collapsed RefEdit box keeps picking when something else is clicked * UI: a dialog opened over a picking RefEdit dialog puts the selection back * UI: arrow keys select on the grid while a dialog is collapsed to a RefEdit box * UI: F4 cycles the anchors of the reference in a RefEdit box * UI: tests for Enter while collapsed and OK after a cross-sheet pick * Docs: RefEdit boxes' keyboard route, Model Range and click-elsewhere behaviour * UI: a confirmed RefEdit dialog stays on the sheet its command showed * UI: scripted dialog cancel and confirm end a RefEdit pick like the buttons * UI: RefEdit boxes run their selection and sheet commands through the app * Docs: RefEdit dialogs after OK, scripted cancel and confirm * UI: a RefEdit dialog's OK stops when the picked sheet's draft cannot be saved * Docs: ui.dialog.confirm applies to command-backed dialogs; a draft that cannot be saved stops OK * UI: Cancel of a RefEdit dialog is never a confirmation, and says when it cannot go home * Docs: cancelling a RefEdit dialog when its sheet cannot be shown again * UI: a RefEdit dialog follows its sheet's rename only through the engine's recorded steps * UI: F4 in a RefEdit box splits areas only at commas outside quoted sheet names * Docs: how a RefEdit dialog follows its sheet's rename * UI: a RefEdit dialog's sheet is told from a new sheet given its name The dialog followed its sheet by name and read the recorded steps only once the name was gone, so a sheet deleted and recreated under its own name between two frames was taken for the dialog's sheet, and OK resolved unqualified references against the replacement. The home sheet is now followed through every recorded step since the dialog last looked: a sheet with its name is the same sheet only while its file id is unchanged (a sheet inserted in the session has none), a rename keeps the file id and the cells, and a deleted sheet is remembered as it was, so only an undo that brings that very sheet back revives it. When the steps are not accounted for, a sheet with the name is taken for it, as before. A sheet never saved to a file and replaced inside one undo group is the one case nothing in the model tells apart; the code comment says so. * Docs: how a RefEdit dialog tells its sheet from a new sheet given its name * UI: a RefEdit dialog follows a sheet that came from a file by its file id A sheet read from a file keeps the file's sheetId, which no step changes and which a sheet inserted in the session lacks, so the dialog's sheet is found by it wherever a recorded step puts it and whatever the step renames it to: a group that renames the sheet and inserts a new one under its old name follows the rename instead of refusing OK. When the step also copied the sheet (a copy keeps the id), the one keeping the name is taken. A sheet never saved to a file is still followed by name, with the rename rule as before. * Docs: a RefEdit dialog follows a sheet that came from a file by its file id * UI: a RefEdit box the dialog stops showing is no longer picked into A reference box compares the grid selection with the one it last saw only while it is drawn, so a box hidden while it was the one being picked into (Solver's Options view, the constraint value once the operator is int or bin) took a selection made meanwhile when it came back. After the dialog's window is drawn, a target box that was not drawn in the frame ends the pick, whatever hid it; Model Range no longer needs its own rule when the Load/Save area is hidden. * UI: the Name Box keeps its keys while a dialog is collapsed to a RefEdit box The collapsed picker took Escape, Enter and the arrow keys out of the frame's input whoever had the keyboard. The Name Box, drawn before the dialogs, had already read an arrow when the picker removed the same event and moved the grid selection, and the box then took that selection and the keyboard; its Escape expanded the dialog instead of only leaving the box. The picker now acts on those keys only when no other text field owns the keyboard, judged by the focus at the end of the frame before as well as now, since egui drops the focus for Escape before anything is drawn. The other field's Escape and Enter are still used up, so the dialog neither closes nor confirms on them. * Docs: a sentence break in the ui.dialog row of the control protocol * UI: a RefEdit dialog keeps its sheet by the sheet's identity, not by file id or history The dialog's sheet was followed by name through the recorded undo steps, with the file's sheet id telling a saved sheet from a new one; a copy made with sheet.move keeps that id, so once the original was deleted the copy, as the only sheet of the id, was taken for the dialog's sheet and OK ran against it. The model now gives every sheet an identity while the app runs (SheetUid: fresh for every sheet added, inserted, copied or read, kept through renames, moves, undo and redo), so Home keeps the sheet's uid and finds the sheet by it each time it is needed. The history walk, the rename rule and the file-id and Arc comparisons are gone, with the undetectable single-step case they left: a sheet given the name, a replacement inside one undo group and a copy are all other sheets, and an undo that brings the original back makes it the dialog's again. Nothing of the unaccounted-steps fallback is kept, since the identity always answers. Regression test: copy the dialog's sheet, delete the original, OK is refused; undo, OK runs on the original. The identity tests no longer need a workbook that came through a file. * UI: Solver's Reset All, Load, Save and Replace act on the dialog's sheet, as Solve does Only Solve went through the dialog's home-sheet checks before running (dialogs::show): Reset All, Load Model, Save Model and Replace ran their commands straight from the form, on whichever sheet was active. After a reference box picked on another sheet and the dialog expanded, that sheet was still shown, so Reset All removed its model instead of the dialog's sheet's, and Load and Save read and wrote the model range on it. The four buttons now run only after the same checks as Solve: the dialog's sheet must still exist (else the command is refused with the deleted-sheet message) and is shown again with the selection from before picking put back (a failure to show it, a text-box draft that could not be saved, is shown as the dialog's error and the command does not run). Goal Seek has no other buttons, and Data Analysis's Replace goes through the confirm path that Solve uses, so neither needed a change. Regression tests: after a cross-sheet pick, Reset All clears the dialog's sheet's model and leaves the other sheet's; Save and Replace write the dialog's sheet; Load reads it. * UI: Protect Sheet opened while a reference box picks on another sheet acts on the dialog's sheet * Docs: a scripted Goal Seek after a cross-sheet pick cancels the dialog before data.goalSeek * UI: the Protect Sheet labels follow the dialog's sheet while a reference box picks on another * Docs: a scripted Goal Seek checks that the dialog's sheet still exists after cancel * Control: ui.dialog.cancel replies sheetDeleted when the dialog's own sheet is gone, even if its name was reused * M2: allow shared-runner variance in the pathological math timing gate * M2: refuse sheet protection actions after a reference dialog home is deleted --------- Co-authored-by: FredApps <FredApps@users.noreply.github.com>
16 KiB
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 --connectandgridcraft_mcp::Remotedo this themselves: they useGRIDCRAFT_CONTROL_TOKENif 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 a pick on another sheet, 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 whose reference boxes (RefEdit) picked on another sheet goes back to its own sheet: 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 or slicer; 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.
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.