UI: a refused ui.pythonMode start is an error with the reason

ui.pythonMode {on: true} (or a switch with Python mode off) answered {"on": false}
when Python mode could not start, so a control or MCP client saw success. It now
returns an error naming why: a locked cell on a protected sheet, a cell or draft
inside another cell's spill, or code hidden on a protected sheet.

Test: a_refused_ui_python_mode_start_is_an_error_with_the_reason (fails without
the fix); python_mode_starts_no_edit_on_hidden_code and
python_mode_does_not_start_on_a_draft_typed_inside_a_spill now expect the error.
This commit is contained in:
prxnvt
2026-11-02 02:52:48 -05:00
parent 23e09713a9
commit 94b292d5a8
7 changed files with 60 additions and 7 deletions
+8 -2
View File
@@ -248,10 +248,16 @@ impl SheetApp {
}
"ui.pythonMode" => {
// `{"on": true}` starts Python mode on the active cell (or switches the edit in
// progress to it), `{"on": false}` leaves it; without `on` it switches.
// progress to it), `{"on": false}` leaves it; without `on` it switches. A start
// that is refused (a locked cell, a cell inside a spill, hidden code) is an error
// with the reason, not `{"on": false}`.
let on = self.editor.as_ref().is_some_and(|e| e.python);
if p.get("on").and_then(Json::as_bool).unwrap_or(!on) != on {
let want = p.get("on").and_then(Json::as_bool).unwrap_or(!on);
if want != on {
self.toggle_python_mode();
if want && !self.editor.as_ref().is_some_and(|e| e.python) {
return Some(Err(self.python_mode_refused()));
}
}
Ok(json!({"on": self.editor.as_ref().is_some_and(|e| e.python)}))
}
+17
View File
@@ -340,6 +340,23 @@ impl SheetApp {
doc.and_then(|d| d.wb.sheet(ed.sheet)).is_some_and(|sh| sh.spill_anchor(ed.cell).is_some())
}
/// Why Python mode didn't start where a command asked for it: the cell lies inside another
/// cell's spill, it is locked on a protected sheet, or its code is hidden there.
pub(crate) fn python_mode_refused(&self) -> String {
let in_spill = match self.editor.as_ref() {
Some(ed) => self.draft_in_spill(ed),
None => formula_bar::active_spill_anchor(self).is_some(),
};
if in_spill {
return "Python mode doesn't start on a cell inside another cell's spill.".into();
}
let locked = self.session.active().and_then(|d| {
let at = d.wb.active().and_then(|sh| sh.merge_at(d.selection.active)).map(|m| m.start).unwrap_or(d.selection.active);
self.session.locked_cell_message(d.wb.active_sheet(), at)
});
locked.unwrap_or_else(|| "Python mode can't start on this cell: its code is hidden on a protected sheet.".into())
}
/// Commits a Python mode edit through `python.set`, on the sheet the edit started on; with no
/// code left, a Python cell is cleared through `cell.set`. When the command refuses (code
/// longer than a cell holds), its message shows and the edit stays open with the code as
+1 -1
View File
@@ -210,7 +210,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
("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}"),
("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}"),
+5 -2
View File
@@ -987,7 +987,7 @@ fn python_ui_commands_are_listed() {
panic!("command discovery must return immediately");
};
let commands = reply["result"].as_array().cloned().unwrap_or_default();
let listed = [("ui.pythonMode", "{on?: bool} (toggles without it) → {on}")];
let listed = [("ui.pythonMode", "{on?: bool} (toggles without it) → {on}; a refused start is an error")];
for (id, docs) in listed {
let command = commands.iter().find(|c| c["id"] == id).unwrap_or_else(|| panic!("engine.commands lists {id}"));
assert_eq!(command["ui"], true, "{id}");
@@ -1245,7 +1245,10 @@ fn python_mode_does_not_start_on_a_draft_typed_inside_a_spill() {
let mut app = SheetApp::new(session, crate::Services::default());
let ctx = egui::Context::default();
call(&mut app, &ctx, "ui.edit.begin", json!({"text": "5"}));
execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true}));
let Outcome::Done(reply) = handle(&mut app, &ctx, "engine.execute", &json!({"command": "ui.pythonMode", "params": {"on": true}})) else {
panic!("ui.pythonMode answers at once");
};
assert_eq!(reply["ok"], false, "the start is refused: {reply}");
assert!(app.editor.as_ref().is_some_and(|e| !e.python && e.text == "5"), "the draft stays an ordinary entry");
call(&mut app, &ctx, "ui.edit.commit", json!({"move": "none"}));
let a2 = app.session.active().and_then(|d| d.wb.sheet(0)).and_then(|sh| sh.cell(gridcraft_engine::core::CellRef::new(1, 0)).cloned());
+3 -1
View File
@@ -110,7 +110,9 @@ fn python_mode_starts_no_edit_on_hidden_code() {
h.get_by_label("Python Mode").click();
h.run_steps(3);
assert_eq!(editor(&h), None, "the PY badge");
assert_eq!(h.state_mut().run("ui.pythonMode", json!({"on": true})).unwrap()["on"], false, "ui.pythonMode");
let refused = h.state_mut().run("ui.pythonMode", json!({"on": true})).expect_err("ui.pythonMode");
assert!(refused.contains("hidden") && !refused.contains("secret"), "{refused}");
assert_eq!(editor(&h), None, "ui.pythonMode");
assert!(!bar_shows_code(&h));
// Ctrl+Enter with no edit open writes nothing over the code.
press(&mut h, Key::Enter, Modifiers::COMMAND);
+25
View File
@@ -295,6 +295,31 @@ fn ui_python_mode_switches_from_the_control_channel_without_a_dialog() {
assert!(h.state().editor.is_none());
}
#[test]
fn a_refused_ui_python_mode_start_is_an_error_with_the_reason() {
let mut s = blank();
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
s.execute("python.set", json!({"cell": "C1", "code": "secret = 1"})).unwrap();
s.execute("home.formatCells", json!({"range": "C1", "style": {"protection": {"locked": false, "hidden": true}}})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
let mut h = harness(s);
// A locked cell (B1), a cell inside a spill (A2), code hidden on a protected sheet (C1), and a
// draft typed inside a spill: each is refused with an error, by `on: true` and by a switch.
for (cell, typed, says) in [("B1", None, "protected"), ("A2", None, "spill"), ("C1", None, "hidden"), ("A2", Some("5"), "spill")] {
h.state_mut().cancel_edit();
h.state_mut().run("ui.message.dismiss", json!({})).unwrap();
h.state_mut().run("selection.set", json!({"cell": cell})).unwrap();
if let Some(text) = typed {
h.state_mut().begin_edit(Some(text.into()), false);
}
for params in [json!({"on": true}), json!({})] {
let e = h.state_mut().run("ui.pythonMode", params.clone()).expect_err(cell);
assert!(e.contains(says), "{cell} {params}: {e}");
assert_ne!(mode(&h), Some(true), "{cell}");
}
}
}
/// Clicks the middle of `a1` on screen.
fn click_cell(h: &mut Harness, a1: &str) {
let at = {
+1 -1
View File
@@ -65,7 +65,7 @@ connection; nothing sent after it runs. Blank lines are skipped.
| `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.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. Opens no dialog |
| `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](#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`) |