diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index f5e5cd599..ba96b4506 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -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)})) } diff --git a/crates/ui-egui/src/app/editing.rs b/crates/ui-egui/src/app/editing.rs index bd7a6ab5e..8682a435c 100644 --- a/crates/ui-egui/src/app/editing.rs +++ b/crates/ui-egui/src/app/editing.rs @@ -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 diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index dddce9759..b58291b69 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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}"), diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 7f876d35c..0f10348ea 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -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()); diff --git a/crates/ui-egui/tests/it/python_hidden.rs b/crates/ui-egui/tests/it/python_hidden.rs index 705728d6c..228d0cfab 100644 --- a/crates/ui-egui/tests/it/python_hidden.rs +++ b/crates/ui-egui/tests/it/python_hidden.rs @@ -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); diff --git a/crates/ui-egui/tests/it/python_mode.rs b/crates/ui-egui/tests/it/python_mode.rs index 79c0b079c..c2516366b 100644 --- a/crates/ui-egui/tests/it/python_mode.rs +++ b/crates/ui-egui/tests/it/python_mode.rs @@ -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 = { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1f3056825..7b754aadb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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`) |