Files
gridcraft/crates/ui-egui/src/control.rs
T
PranavandFredApps 7bb397a3c1 UI: RefEdit reference boxes in dialogs (Goal Seek, Solver, Data Analysis) (#494)
* 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>
2026-11-02 21:23:18 +01:00

640 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Programmatic control of the running app (agents, tests, MCP).
//!
//! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`):
//! - `engine.execute {command, params}` — run any engine or UI command
//! - `engine.commands {search?}` — every command with enablement and params docs
//! - `document.inspect`, `sheet.read {range?}`, `cell.get {cell?}` — read state
//! - `ui.inspect` — UI state (ribbon tab, editor, dialog, selected object, grid geometry, perf)
//! - `ui.object.select {id?, kind?}` — select a floating object; replies with its rect
//! - `ui.click {x, y, button?, count?, shift?, cmd?, alt?}`, `ui.drag {x, y, toX, toY}`,
//! `ui.move {x, y}` — real pointer input in window points
//! - `ui.cell.click {cell}`, `ui.cell.drag {from, to}` — pointer input on a cell's centre
//! - `ui.key {key, shift?, cmd?, alt?, ctrl?}`, `ui.text {text}` — keyboard input
//! - `ui.ribbon {tab}`, `ui.dialog {name}`, `ui.dialog.set {field, value}`,
//! `ui.dialog.confirm`, `ui.dialog.cancel`, `ui.message.dismiss`
//! - `ui.edit.begin {text?}`, `ui.edit.commit {move?}`, `ui.edit.cancel`
//! - `ui.screenshot {path?}` — PNG of the window (base64 when no path); animations are settled
//! - `ui.resize {width, height}`, `ui.set {dark?, formulaBar?, ribbonCollapsed?, animations?}`
//! - `app.open {path, password?}`, `app.save {path?}`, `app.quit`
use std::sync::mpsc::Sender;
use craft_ui::capture::CaptureSlot;
use gridcraft_engine::core::CellRef;
use serde_json::{Value as Json, json};
use crate::SheetApp;
mod screenshots;
mod synthetic;
pub use screenshots::{
CaptureGeometry, SCREENSHOT_DOCUMENT_CHANGED, SCREENSHOT_MAX_AGGREGATE_BYTES, SCREENSHOT_MAX_BASE64_BYTES, SCREENSHOT_MAX_ENCODING_BYTES,
SCREENSHOT_MAX_IMAGE_BYTES, SCREENSHOT_MAX_PEAK_BYTES, SCREENSHOT_MAX_REQUESTS, SCREENSHOT_MAX_RETRIES, SCREENSHOT_VIEWPORT_CHANGED, Shots,
UNKNOWN_CAPTURE_BYTES, drain_screenshots, encoding_working_bytes, issue_screenshots, save_image,
};
use screenshots::{Shot, check_encoding_budget, check_peak_budget, reject_shot};
#[cfg(test)]
use screenshots::{base64_len, png_worst_bytes, validate_image};
pub(crate) use screenshots::{current_capture_geometry, note_capture_geometry};
pub use synthetic::{MAX_QUEUED_SYNTHETIC_BYTES, MAX_QUEUED_SYNTHETIC_EVENTS, MAX_SYNTHETIC_TEXT_BYTES};
use synthetic::{cell_center, key_from, modifiers, push_click, push_drag, push_key, push_move, push_text};
pub type ControlResponse = Json;
pub struct ControlRequest {
pub method: String,
pub params: Json,
pub reply: Sender<ControlResponse>,
/// Who has the request: [`PENDING`], [`TAKEN`] (the app runs it) or [`ABANDONED`] (the sender
/// gave up waiting, so the app must not run it late: a client told its request timed out had
/// it applied anyway on the next UI pass, and an agent that retried ran it twice (#1914).
pub state: std::sync::Arc<std::sync::atomic::AtomicU8>,
}
/// Queued, not yet run.
pub const PENDING: u8 = 0;
/// The app has started it: it will answer.
pub const TAKEN: u8 = 1;
/// Its sender gave up waiting (the reply timeout expired): it is dropped unrun.
pub const ABANDONED: u8 = 2;
impl ControlRequest {
pub fn new(method: impl Into<String>, params: Json) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
let (tx, rx) = std::sync::mpsc::channel();
(Self { method: method.into(), params, reply: tx, state: Default::default() }, rx)
}
/// Claim the request for running; false when its sender already gave up on it.
pub fn take(&self) -> bool {
use std::sync::atomic::Ordering;
self.state.compare_exchange(PENDING, TAKEN, Ordering::SeqCst, Ordering::SeqCst).is_ok() || self.state.load(Ordering::SeqCst) == TAKEN
}
/// Give up on the request; false when the app has already started it (it will answer).
pub fn abandon(state: &std::sync::atomic::AtomicU8) -> bool {
use std::sync::atomic::Ordering;
state.compare_exchange(PENDING, ABANDONED, Ordering::SeqCst, Ordering::SeqCst).is_ok() || state.load(Ordering::SeqCst) == ABANDONED
}
}
pub enum Outcome {
Done(Json),
Screenshot { path: Option<String> },
}
fn ok(v: Json) -> Outcome {
Outcome::Done(json!({"ok": true, "result": v}))
}
fn err(e: impl std::fmt::Display) -> Outcome {
Outcome::Done(json!({"ok": false, "error": e.to_string()}))
}
fn wrap(r: Result<Json, String>) -> Outcome {
match r {
Ok(v) => ok(v),
Err(e) => err(e),
}
}
/// Runs a command for an agent. Commands that move the selection scroll the active cell into
/// view, as the Go To dialog, the Name Box and the arrow keys do.
fn run(app: &mut SheetApp, id: &str, params: Json) -> Result<Json, String> {
let r = app.run(id, params);
if r.is_ok() && matches!(id, "edit.goTo" | "edit.goToSpecial" | "selection.set" | "selection.move") {
app.grid.ensure_visible = true;
}
r
}
/// [`run`] in another window (`window` or `doc` from the request), bringing back the active
/// window afterwards unless the command itself switched windows.
fn run_in(app: &mut SheetApp, target: gridcraft_engine::Target, id: &str, params: Json) -> Result<Json, String> {
let guard = app.session.enter_target(target).map_err(|e| e.to_string())?;
let r = run(app, id, params);
app.session.leave_target(guard);
r
}
/// A command called by its id as the method. Its params may name a `window` or `doc` to act in,
/// except for the window commands, whose own params those are.
fn direct(app: &mut SheetApp, id: &str, p: &Json, via_app: bool) -> Result<Json, String> {
let mut params = p.clone();
let target = if gridcraft_engine::windows::is_window_command(id) {
gridcraft_engine::Target::Active
} else {
let t = gridcraft_engine::Target::from_json(p).map_err(|e| e.to_string())?;
if let Some(o) = params.as_object_mut() {
o.remove("window");
o.remove("doc");
}
t
};
if via_app {
return run_in(app, target, id, params);
}
let guard = app.session.enter_target(target).map_err(|e| e.to_string())?;
let r = app.session.run(id, params);
app.session.leave_target(guard);
r
}
pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json {
let r = ctx.content_rect();
let v = app.view();
json!({
"ui": serde_json::to_value(&app.ui).unwrap_or_default(),
"window": [r.width(), r.height()],
"grid": app.grid.cells_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
"scroll": [v.scroll.x, v.scroll.y],
"editor": app.editor.as_ref().map(|e| editor_json(app, e)),
"dialog": app.dialog.as_ref().map(|d| json!({"name": d.name, "title": d.title, "values": d.values, "tab": d.tab})),
"backstage": app.backstage.as_ref().map(|b| b.page.id()),
"message": app.message.as_ref().map(|(t, m)| json!({"title": t, "text": m})),
"selectedObject": app.selected_object,
// The id when the selection is a chart, as before `selectedObject`.
"selectedChart": app.selected_chart(),
"mode": format!("{:?}", app.session.mode),
"documents": app.session.documents().iter().map(|d| json!({"title": d.display_title(), "dirty": d.is_dirty()})).collect::<Vec<_>>(),
"activeDocument": app.session.active_index(),
"windows": app.session.window_list(),
"activeWindow": app.session.active_window(),
"perf": {"frameMs": app.perf.frame_ms, "gridMs": app.perf.grid_ms, "fps": app.perf.fps},
})
}
/// The open edit, for `ui.inspect`. The draft is left out (`text` is null, `hidden` true) while
/// its cell's code is hidden on a protected sheet, checked now: a protection command earlier in
/// the same batch runs before the next frame. That holds for a draft in Python mode, and for a
/// draft that began on a Python cell's code, since leaving Python mode keeps the code as an
/// ordinary draft; a draft typed over a Python cell holds only what was typed. The sheet is the
/// draft's own, found by identity: a sheet inserted, deleted or moved in the same batch puts
/// another at its index. With its sheet gone, such a draft is left out too.
fn editor_json(app: &SheetApp, e: &crate::editor::EditState) -> Json {
let code = e.python || e.py_cell;
let hidden = code
&& app.session.documents().iter().find(|d| d.uid == e.doc).is_some_and(|d| match e.sheet_in(&d.wb).and_then(|i| d.wb.sheet(i)) {
Some(sh) => gridcraft_engine::display::formula_hidden(&d.wb, sh, e.cell),
None => true,
});
let text = if hidden { Json::Null } else { json!(e.text) };
json!({"cell": e.cell.a1(), "text": text, "hidden": hidden, "formulaBar": e.from_formula_bar, "point": e.can_point(), "python": e.python})
}
/// `ui.dialog.cancel`: closes the open dialog as its Cancel button does, going back to the sheet
/// and selection from before a reference box picked.
fn dialog_cancel(app: &mut SheetApp) -> Outcome {
// The macro step editor's draft, overlays and debug run go with its own close.
if app.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") {
return match crate::macro_editor::close_now(app) {
Ok(()) => ok(Json::Null),
Err(e) => err(e),
};
}
// Back to the sheet and selection from before a reference box picked, as Cancel does.
// When the dialog's sheet can't be shown again (a text-box draft on the picked sheet
// could not be saved), the dialog stays and the script gets the reason; the window's
// Cancel closes it and shows the reason, since a person can see the message.
// A dialog whose own sheet was deleted (found by identity, so a new sheet given its
// name does not count) replies `{"sheetDeleted": true}`: the sheet shown is not the
// dialog's, and a script stops before running the dialog's command itself.
let mut deleted = false;
if let Some(mut d) = app.dialog.take() {
deleted = d.home_sheet_deleted(app);
if let Err(e) = d.end_pick(app) {
app.dialog = Some(d);
return err(e);
}
}
ok(if deleted { json!({"sheetDeleted": true}) } else { Json::Null })
}
/// `ui.object.select {id?, kind?}`: selects the object with that id on the active sheet (its kind
/// is found from the id when left out, and must match when given); no `id` clears the selection.
/// Replies with the object and its rect in window points, for `ui.click` and `ui.drag`.
fn select_object(app: &mut SheetApp, p: &Json) -> Outcome {
use gridcraft_engine::model::{ObjKind, ObjRef};
let Some(id) = p.get("id").and_then(Json::as_u64) else {
app.selected_object = None;
return ok(Json::Null);
};
let Some(sh) = app.session.active().and_then(|d| d.wb.active()) else { return err("no document") };
let found = u32::try_from(id).ok().and_then(|id| match p.get("kind").and_then(Json::as_str) {
Some(k) => ObjKind::parse(k).map(|k| ObjRef::new(k, id)).filter(|o| sh.has_object(*o)),
None => sh.object_ref(id),
});
let Some(o) = found else { return err("no such object on the active sheet") };
let rect = app.grid.rect.and_then(|r| {
let geo = crate::grid::Geo::new(sh, r, app.view().scroll);
crate::chartview::object_rect(&geo, sh, o).map(|r| geo.mirror_rect(r))
});
app.selected_object = Some(o);
ok(json!({"selected": o, "rect": rect.map(|r| [r.left(), r.top(), r.width(), r.height()])}))
}
/// Keeps the commands whose id, label or ribbon path contains `search` (case-insensitive); `None` keeps all.
fn filter_commands(commands: Vec<Json>, search: Option<&str>) -> Vec<Json> {
let Some(q) = search.map(str::to_ascii_lowercase) else { return commands };
let has = |c: &Json, k: &str| c.get(k).and_then(Json::as_str).is_some_and(|v| v.to_ascii_lowercase().contains(&q));
let in_menu = |c: &Json| {
let menu = c.get("menu").and_then(Json::as_array);
menu.is_some_and(|m| m.iter().filter_map(Json::as_str).any(|v| v.to_ascii_lowercase().contains(&q)))
};
commands.into_iter().filter(|c| has(c, "id") || has(c, "label") || in_menu(c)).collect()
}
/// Dispatches one control request: commands, inspection, UI state, dialogs, editing, synthetic input, open/save/quit and screenshots. Unknown methods (and unknown command ids) return an error. Immediate answers request a repaint; `ui.screenshot` is admitted asynchronously instead of being answered here.
pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -> Outcome {
let s = |k: &str| p.get(k).and_then(Json::as_str);
let f = |k: &str| p.get(k).and_then(Json::as_f64).unwrap_or(0.0) as f32;
let out = match method {
"engine.execute" | "command" | "ui.menu.invoke" => {
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
let params = p.get("params").cloned().unwrap_or(json!({}));
let target = match gridcraft_engine::Target::from_json(p) {
Ok(t) => t,
Err(e) => return err(e),
};
wrap(run_in(app, target, id, if params.is_null() { json!({}) } else { params }))
}
"engine.commands" => {
let mut v: Vec<Json> = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
let language_params = format!("{{language: {}}} (alias: code)", crate::i18n::Language::code_list("|"));
for (id, label, params) in [
("ui.ribbonTab", "Ribbon Tab", ""),
("app.checkForUpdates", "Check for Updates…", ""),
("view.collapseRibbon", "Collapse Ribbon", ""),
("view.expandFormulaBar", "Expand Formula Bar", "{on?: bool} (toggles without it) → {on}"),
("ui.pythonMode", "Python Mode", "{on?: bool} (toggles without it) → {on}; a refused start is an error"),
("view.darkMode", "Dark Mode", ""),
("view.theme", "Display Theme", ""),
("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"),
("options.autoComplete", "Enable AutoComplete for cell values", "{on?: bool} (toggles without it) → {on}"),
("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"),
("ui.dialog", "Open Dialog", ""),
("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|account|close}"),
(
"ui.printSettings",
"Print Settings",
"{printer?: discovered name, copies?: 1..999, duplex?: printerDefault|off|longEdge|shortEdge|null, grayscale?: bool, refresh?: bool} → {printer,copies,duplex,grayscale,printers,ready,busy,discovering,status,detail}; {} inspects without submitting",
),
(
"ui.printSubmit",
"Submit Print Job",
"existing file.print sheet/pagination params → {printing,bytes,pending}; submits without opening a dialog",
),
("app.language.set", "Interface Language", language_params.as_str()),
("app.language.english", "Interface Language: English", ""),
("app.language.japanese", "Interface Language: Japanese", ""),
] {
v.push(json!({"id": id, "label": label, "params": params, "enabled": true, "ui": true}));
}
ok(Json::Array(filter_commands(v, s("search"))))
}
"engine.journal" => ok(json!(app.session.journal.iter().map(|(id, p)| json!({"command": id, "params": p})).collect::<Vec<_>>())),
"document.inspect" | "sheet.read" | "cell.get" | "selection.stats" => wrap(direct(app, method, p, false)),
"ui.inspect" => ok(inspect(app, ctx)),
"ui.ribbon" => {
app.ui.ribbon_tab = s("tab").unwrap_or("Home").to_string();
app.ui.ribbon_collapsed = false;
ok(Json::Null)
}
"ui.set" => {
if let Some(v) = p.get("dark").and_then(Json::as_bool) {
app.ui.dark = v;
app.ui.system_theme = false;
}
if let Some(v) = p.get("formulaBar").and_then(Json::as_bool) {
let _ = app.run("view.formulaBar", json!({"on": v}));
}
if let Some(v) = p.get("ribbonCollapsed").and_then(Json::as_bool) {
app.ui.ribbon_collapsed = v;
}
if let Some(v) = p.get("animations").and_then(Json::as_bool) {
app.ui.animations = v;
}
let mut out = serde_json::to_value(&app.ui).unwrap_or_default();
if let Some(o) = out.as_object_mut() {
o.insert("formulaBar".into(), json!(app.session.prefs.formula_bar));
}
ok(out)
}
"ui.dialog" => {
app.open_dialog(s("name").unwrap_or(""), p.clone());
ok(json!({"dialog": app.dialog.as_ref().map(|d| d.name.clone())}))
}
"ui.dialog.set" => match app.dialog.as_mut() {
Some(d) => {
let Some(field) = s("field") else { return err("missing `field`") };
if field == "tab" {
d.tab = s("value").unwrap_or("").to_string();
} else if field == "search" {
d.search = s("value").unwrap_or("").to_string();
} else {
d.set_value(field, p.get("value").cloned().unwrap_or(Json::Null));
}
ok(json!(d.values))
}
None => err("no dialog is open"),
},
"ui.dialog.confirm" => match app.dialog.take() {
Some(mut d) => match d.command {
Some(cmd) => {
// References without a sheet name mean the sheet the dialog was opened on,
// as OK in the window has it: refused once that sheet is deleted, else shown
// again before the command runs.
if d.home_sheet_deleted(app) {
app.dialog = Some(d);
return err(crate::dialogs::SHEET_DELETED);
}
if let Err(e) = d.end_pick(app) {
// The dialog's sheet can't be shown again (a text-box draft on the picked
// sheet could not be saved): the command stays unrun, the dialog open.
app.dialog = Some(d);
return err(e);
}
let params = crate::dialogs::build_params(&d);
let home = app.ui_window();
let r = app.run(cmd, params);
if r.is_err() {
app.return_dialog(home, d);
}
wrap(r)
}
None => {
app.dialog = Some(d);
err("this dialog has no OK command; drive it with its own commands")
}
},
None => err("no dialog is open"),
},
"ui.dialog.cancel" => dialog_cancel(app),
"ui.object.select" => select_object(app, p),
"ui.message.dismiss" => {
app.message = None;
ok(Json::Null)
}
"ui.edit.begin" => {
app.begin_edit(s("text").map(str::to_string), false);
ok(Json::Null)
}
"ui.edit.text" => match app.editor.as_mut() {
Some(e) => {
e.text = s("text").unwrap_or("").to_string();
e.caret = e.text.chars().count();
ok(Json::Null)
}
None => err("not editing"),
},
"ui.edit.commit" => {
let (dr, dc) = match s("move") {
Some("right") => (0, 1),
Some("up") => (-1, 0),
Some("left") => (0, -1),
Some("none") => (0, 0),
_ => (1, 0),
};
if app.commit_edit(dr, dc, false, false) {
ok(Json::Null)
} else {
err(app.message.clone().map(|m| m.1).unwrap_or_else(|| "could not commit".into()))
}
}
"ui.edit.cancel" => {
app.cancel_edit();
ok(Json::Null)
}
"ui.key" => {
let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") };
match push_key(app, k, modifiers(p)) {
Ok(()) => ok(Json::Null),
Err(e) => err(e),
}
}
"ui.text" => {
let text = s("text").unwrap_or("");
// Check the borrowed payload first: do not make a second, potentially large copy
// before the per-event bound is known to hold.
if text.len() > MAX_SYNTHETIC_TEXT_BYTES {
return err(format!("text input is {} bytes, over the {MAX_SYNTHETIC_TEXT_BYTES}-byte limit for one event", text.len()));
}
match push_text(app, text.to_string()) {
Ok(()) => ok(Json::Null),
Err(e) => err(e),
}
}
"ui.move" => match push_move(app, egui::pos2(f("x"), f("y"))) {
Ok(()) => ok(Json::Null),
Err(e) => err(e),
},
"ui.click" => {
let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary };
let count = p.get("count").and_then(Json::as_u64).unwrap_or(1).min(u64::from(u32::MAX)) as u32;
match push_click(app, egui::pos2(f("x"), f("y")), button, count, modifiers(p)) {
Ok(()) => ok(Json::Null),
Err(e) => err(e),
}
}
"ui.drag" => {
let steps = p.get("steps").and_then(Json::as_u64).unwrap_or(8).min(u64::from(u32::MAX)) as usize;
match push_drag(app, egui::pos2(f("x"), f("y")), egui::pos2(f("toX"), f("toY")), steps, modifiers(p)) {
Ok(()) => ok(Json::Null),
Err(e) => err(e),
}
}
"ui.cell.click" => {
let Some(c) = s("cell").and_then(CellRef::parse) else { return err("missing `cell`") };
let Some(pos) = cell_center(app, c) else { return err("no grid yet") };
let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary };
let count = p.get("count").and_then(Json::as_u64).unwrap_or(1).min(u64::from(u32::MAX)) as u32;
match push_click(app, pos, button, count, modifiers(p)) {
Ok(()) => ok(json!({"x": pos.x, "y": pos.y})),
Err(e) => err(e),
}
}
"ui.cell.drag" => {
let (Some(a), Some(b)) = (s("from").and_then(CellRef::parse), s("to").and_then(CellRef::parse)) else {
return err("missing `from`/`to`");
};
let (Some(pa), Some(pb)) = (cell_center(app, a), cell_center(app, b)) else { return err("no grid yet") };
match push_drag(app, pa, pb, 8, modifiers(p)) {
Ok(()) => ok(Json::Null),
Err(e) => err(e),
}
}
"ui.resize" => {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(f("width").max(400.0), f("height").max(300.0))));
ok(Json::Null)
}
"ui.focus" => {
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
ok(Json::Null)
}
"ui.screenshot" => return Outcome::Screenshot { path: s("path").map(str::to_string) },
"app.open" => {
let Some(path) = s("path") else { return err("missing `path`") };
// Not `open_path`: that asks for a missing password in a dialog, and programmatic
// calls never open one. A password-protected workbook takes `password` instead.
let r = app.run("file.open", json!({"path": path, "password": s("password")}));
if let Ok(r) = &r {
app.after_open(Some(path), r);
}
app.after_engine();
match (r, app.message.take()) {
(Err(e), _) => err(crate::clean_error(&e)),
(Ok(_), Some((_, m))) => err(m),
(Ok(_), None) => ok(json!({"title": app.session.active().map(|d| d.display_title())})),
}
}
"app.save" => wrap(app.run("file.save", json!({"path": s("path")}))),
"app.quit" => {
// Programmatic calls never open dialogs: quitting from here doesn't ask about unsaved work.
app.allow_close = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
ok(Json::Null)
}
// Anything else that names a command runs it.
other if gridcraft_engine::find_command(other).is_some() => wrap(direct(app, other, p, true)),
other => err(format!("unknown method `{other}`")),
};
ctx.request_repaint();
out
}
/// Drains queued control requests, drops requests whose client stopped waiting, handles immediate outcomes and admits screenshots, then requests a repaint while synthetic input remains.
pub fn poll(app: &mut SheetApp, ctx: &egui::Context) {
let Some(rx) = app.control_rx.take() else { return };
while let Ok(req) = rx.try_recv() {
// A request whose sender stopped waiting is dropped unrun: running it would apply a
// command the client was told had failed, and its reply would go nowhere (#1914).
if !req.take() {
continue;
}
let outcome = handle(app, ctx, &req.method, &req.params);
match outcome {
Outcome::Done(v) => {
let _ = req.reply.send(v);
}
Outcome::Screenshot { path } => admit(app, ctx, path, req.reply),
}
}
app.control_rx = Some(rx);
if !app.synthetic.is_empty() {
ctx.request_repaint();
}
}
/// Put the request token on every screenshot reply (success, rejection, timeout), so a client can
/// correlate an asynchronous capture even when the reply order differs from the request order.
fn with_token(mut reply: Json, token: u64) -> Json {
if let Some(object) = reply.as_object_mut() {
object.insert("request".into(), json!(token));
}
reply
}
/// Returns the next screenshot token, wrapping and skipping zero.
fn next_token(app: &mut SheetApp) -> u64 {
let mut token = app.shots.token.wrapping_add(1);
if token == 0 {
token = 1;
}
app.shots.token = token;
token
}
/// Admit one screenshot request against the count and byte budgets **before** any capture is
/// issued. A rejected request gets one error reply and never evicts an earlier request.
fn admit(app: &mut SheetApp, ctx: &egui::Context, path: Option<String>, reply: Sender<ControlResponse>) {
let token = next_token(app);
let geometry = current_capture_geometry(app, ctx);
let expected = geometry.expected_size();
let base64 = path.is_none();
// A minimized or not-yet-laid-out window has no size: reserve the largest image the
// per-image limit allows and keep the request queued. Issuance waits for a real size, and the
// deadline reports a timeout if none arrives.
let bytes = match expected {
Some(size) => {
let Some(bytes) = geometry.retained_bytes() else {
reject_shot(reply, token, "screenshot failed: the window size overflows the screenshot budget");
return;
};
if bytes > SCREENSHOT_MAX_IMAGE_BYTES {
reject_shot(
reply,
token,
format!(
"screenshot budget exceeded: {bytes} bytes for one {}×{} image, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit",
size[0], size[1]
),
);
return;
}
if let Err(message) = check_encoding_budget(size, base64) {
reject_shot(reply, token, message);
return;
}
bytes
}
None => UNKNOWN_CAPTURE_BYTES,
};
if app.shots.requests.len() >= SCREENSHOT_MAX_REQUESTS {
reject_shot(
reply,
token,
format!("screenshot budget exceeded: {} requests are already outstanding (limit {SCREENSHOT_MAX_REQUESTS})", app.shots.requests.len()),
);
return;
}
let reserved = app.shots.reserved().saturating_add(bytes);
if reserved > SCREENSHOT_MAX_AGGREGATE_BYTES {
reject_shot(
reply,
token,
format!(
"screenshot budget exceeded: {reserved} bytes reserved by outstanding requests, over the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit"
),
);
return;
}
if let Some(size) = expected
&& let Err(message) = check_peak_budget(reserved, size, base64)
{
reject_shot(reply, token, message);
return;
}
let now = crate::monotonic_ms();
let document = app.session.active().map(|d| d.uid);
let epoch = note_capture_geometry(app, geometry);
app.shots.requests.push(Shot {
token,
path,
reply,
deadline: now + 8000.0,
ready_at: now + 100.0,
frames: 0,
settle_pass: None,
issued: false,
retries: 0,
geometry,
viewport_id: geometry.viewport_id,
document,
epoch,
expected,
bytes,
// Issued only from `ui`, so the slot is created there against the geometry in force.
slot: None,
});
// A queued request with a capturable size has not issued a paint-requesting command yet;
// `logic` keeps asking for hidden paints until `issue_screenshots` runs. A size-less request
// asks for none (see `Shots::needs_hidden_paint`).
ctx.request_repaint();
}
/// Builds the standard token-carrying timeout reply for a screenshot request.
fn timeout_reply(message: &str, token: u64) -> Json {
json!({"ok": false, "error": message, "request": token})
}
#[cfg(test)]
mod tests;