mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-03 00:41:56 -06:00
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:
@@ -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)}))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
Reference in New Issue
Block a user