mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-07 02:51:33 +00:00
The Insert tab gains Button, Slider and Spin Button beside Cell Controls › Checkbox, which is unchanged. Each arms placement: a drag on the grid draws the control at that size (a tall drag makes a vertical slider), a click places it at its default size, and Escape disarms. Key tips (N B U, N S L, N S B) place one at the active cell. A new button opens Assign Action, as Excel opens Assign Macro. form_controls.rs draws the controls in the sheet tokens, so they look the same in both themes: buttons with hover and pressed states, sliders with a track, a filled part and a thumb (and the value beside the thumb while it is dragged), spin buttons with two arrows, stacked or side by side. A plain click works a control: dragging the thumb, clicking the track (a page), clicking or holding an arrow (it repeats after 0.4 s), pressing a button (it runs on release over it). A drag or a hold is one undo step. The control last worked takes the arrows, Page Up and Page Down, Home and End, and Space or Enter for a button. Ctrl or Cmd+click or a right-click selects a control to move or resize it; a right-click opens the object menu, which for a control adds Edit Text and Assign Action (buttons) and Format Control before Delete. Double-clicking a selected control opens its editor. On a right-to-left sheet a control moves as a whole and works where it is drawn. Assign Action runs a macro (from the list or typed, on the selection or a fixed range, with an optional output cell) or a command (searched among those a macro may run, with JSON parameters). Format Control edits the value, limits, incremental and page change, cell link, orientation, name and the locked and print flags, checking the same rules as the engine. A button whose action needs approval, or whose macro would write over data, asks in a prompt whose buttons press it again with the answer. New theme tokens (thumb, hover, pressed, fill, focus ring) are in the contrast test; Button, Slider and Spin icons are drawn in code; the new labels are translated in every interface language. The catalog gains Developer › Controls (280 / 296).
154 lines
13 KiB
Markdown
154 lines
13 KiB
Markdown
# GridCraft control protocol
|
||
|
||
Start the desktop app with a control port:
|
||
|
||
```sh
|
||
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:
|
||
|
||
```json
|
||
{"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, 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 (no `id` clears the selection); replies `{selected, rect}`, the rect in window points |
|
||
| `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 |
|
||
| `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](#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`, `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. Form controls are worked with
|
||
`control.setValue` and `control.press`, and listed by `control.list`.
|
||
`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`](mcp.md) and [`cli.md`](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.
|