From c1cc8264feb7be2904e0bbba78a2dc50b1c5c9f1 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:13:05 -0500 Subject: [PATCH 01/10] Model: sheets carry an identity while the app runs Each sheet gets an identity (gridcraft_model::SheetUid, Sheet::uid) for what must follow one sheet while sheets are inserted, deleted, moved or renamed, which a sheet index or a name can't do. It lives only while the app runs: it isn't saved to JSON or a file, and a new, inserted, copied or opened sheet gets a fresh one. A rename, a move and the copies undo keeps don't change it, so undo of a deletion brings back the same sheet. Sheets compare by content, whatever their identity: any two identities compare equal, and SheetUid::get compares them. sheet.move with copy gives the copy a fresh identity. Tests cover new sheets, clones, JSON, renames, moves, deletion and undo, copies and a workbook read from a file. --- crates/engine/src/cmd/sheet/tabs.rs | 2 + crates/engine/src/tests/seams.rs | 26 +++++++++++++ crates/model/src/lib.rs | 2 +- crates/model/src/sheet.rs | 57 +++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/sheet/tabs.rs b/crates/engine/src/cmd/sheet/tabs.rs index a20db5291..2c9425dca 100644 --- a/crates/engine/src/cmd/sheet/tabs.rs +++ b/crates/engine/src/cmd/sheet/tabs.rs @@ -159,6 +159,8 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result { let Some(src) = cx.wb.sheets.get(i).cloned() else { return Err(bad("sheet.move", "no such sheet")) }; if copy { let mut sh = (*src).clone(); + // The copy is a sheet of its own, not the one it was copied from. + sh.uid = gridcraft_model::SheetUid::fresh(); let base = sh.name.clone(); // `Name (2)`: the name is shortened so the suffix fits in Excel's 31 characters. let name = (2..) diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 9491c3773..9615257ff 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -1135,3 +1135,29 @@ fn a_linked_paste_carries_the_files_trust_whatever_it_pastes() { assert!(sheet(&s).cell(b1).is_some_and(|c| c.formula.is_some()), "a linked paste of formats writes a formula"); assert_eq!(trust(&s), Trust::FromFile); } + +#[test] +fn a_sheet_keeps_its_identity_until_it_is_copied_or_read_from_a_file() { + let mut s = s(); + let uids = |s: &Session| s.doc().unwrap().wb.sheets.iter().map(|sh| sh.uid.get()).collect::>(); + let first = uids(&s)[0]; + // Renamed, moved, deleted and brought back by undo: the same sheet. + s.execute("home.insertSheet", json!({"name": "Other"})).unwrap(); + s.execute("sheet.rename", json!({"sheet": "Sheet1", "name": "Data"})).unwrap(); + s.execute("sheet.move", json!({"sheet": "Data", "to": 1})).unwrap(); + let data = s.doc().unwrap().wb.sheet_index("Data").unwrap(); + assert_eq!(uids(&s)[data], first); + s.execute("home.deleteSheet", json!({"sheet": "Data"})).unwrap(); + assert!(!uids(&s).contains(&first)); + s.execute("edit.undo", json!({})).unwrap(); + assert!(uids(&s).contains(&first), "undo brings the same sheet back"); + // A copy, or a new sheet of the same name, is another sheet. + s.execute("sheet.move", json!({"sheet": "Data", "to": 0, "copy": true})).unwrap(); + let all = uids(&s); + assert_eq!(all.len(), 3); + assert_eq!(all.iter().collect::>().len(), 3, "{all:?}"); + // Read from a file: new sheets. + let bytes = crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap(); + let (read, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap(); + assert!(read.sheets.iter().all(|sh| !all.contains(&sh.uid.get()))); +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index b9b67f519..9df99c3d1 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -23,7 +23,7 @@ pub use macros::*; pub use password::PasswordHash; pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedSheet, PreservedXml}; pub use python::{PyCode, PyOutput}; -pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility, points_to_chars}; +pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetUid, SheetViewMode, Visibility, points_to_chars}; pub use store::CellStore; pub use style::{ Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline, diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index 102a41317..9faebbd5e 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use gridcraft_core::{CellRef, RangeRef, Value}; use serde::{Deserialize, Serialize}; @@ -52,9 +53,44 @@ impl SheetViewMode { } } +/// A sheet's identity while the app runs, for what must follow one sheet (an open cell draft): a +/// sheet made new (added, inserted, copied, read from a file or from JSON) gets a fresh one, and it +/// stays with the sheet through renames, moves and the copies undo keeps. Never saved. +/// It is not content: any two compare equal, so comparing sheets compares what they hold; compare +/// identities with [`SheetUid::get`]. +#[derive(Clone, Copy, Debug)] +pub struct SheetUid(u64); + +/// The next [`SheetUid`]; 0 is never given out. +static NEXT_SHEET_UID: AtomicU64 = AtomicU64::new(1); + +impl SheetUid { + pub fn fresh() -> SheetUid { + SheetUid(NEXT_SHEET_UID.fetch_add(1, Ordering::Relaxed)) + } + pub fn get(self) -> u64 { + self.0 + } +} + +impl Default for SheetUid { + fn default() -> SheetUid { + SheetUid::fresh() + } +} + +impl PartialEq for SheetUid { + fn eq(&self, _: &SheetUid) -> bool { + true + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Sheet { pub name: String, + /// The sheet's identity while the app runs ([`SheetUid`]). Not in JSON or the file. + #[serde(skip)] + pub uid: SheetUid, /// The `sheetId` the file gave the sheet, written back on save so that what refers to the /// sheet by its id keeps finding it; 0 for a sheet the file did not have. Not in JSON. #[serde(skip)] @@ -149,6 +185,7 @@ impl Sheet { pub fn new(name: impl Into) -> Sheet { Sheet { name: name.into(), + uid: SheetUid::fresh(), file_id: 0, cells: CellStore::new(), cell_pictures: BTreeMap::new(), @@ -514,6 +551,26 @@ mod tests { use super::*; use crate::cell::Formula; + #[test] + fn a_sheet_keeps_its_identity_through_clones_and_a_new_sheet_gets_its_own() { + let a = Sheet::new("A"); + let b = Sheet::new("A"); + assert_ne!(a.uid.get(), b.uid.get(), "two new sheets"); + assert_ne!(a.uid.get(), 0); + // A clone (an undo snapshot, a rename through Arc::make_mut) is the same sheet. + let mut renamed = a.clone(); + renamed.name = "B".into(); + assert_eq!(renamed.uid.get(), a.uid.get()); + // Not content: equal sheets compare equal whatever their identity. + assert_eq!(a, b); + // Not in JSON: a sheet read back is a new one. + let json = serde_json::to_string(&a).unwrap(); + assert!(!json.contains("uid"), "{json}"); + let read: Sheet = serde_json::from_str(&json).unwrap(); + assert_ne!(read.uid.get(), a.uid.get()); + assert_eq!(read, a); + } + #[test] fn line_index_matches_linear() { let mut s = Sheet::new("S"); From 8b96d30c2b63817b484d341bf62514ef87ed08c8 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:57:10 -0400 Subject: [PATCH 02/10] Engine: python.output switches a Python cell between Python Object and Excel Value A Python cell's return type had no command of its own: python.set takes it, but only together with the code. python.output {cell?, mode?} sets it on its own; its command entry lists Excel's shortcut, Ctrl+Alt+Shift+M. Without a mode it switches to the other one, so the shortcut needs no parameters; a cell that isn't a Python cell is an error. A cached result belongs to the return type it was made for, so a switch treats it as python.set treats a new return type: the result goes, the cell shows #BLOCKED! until it runs, and its old spill goes with what read it recalculated. Both commands write the cell through one path, so python.output refuses a table header and a locked cell on a protected sheet as python.set does. Each switch is one undo step, and setting the mode a cell already has adds none. python.set now refuses an output that isn't text instead of ignoring it. The command-sequence fuzzer runs python.output too. A macro recording keeps the return type a switch without a mode set, as its mode (as Bold records on), also for a switch inside a recorded script run, so playing it back sets that type rather than switching again. Tests: switching without a mode drops the cached result, its spill and the value its reader saw, the same as python.set with the other return type; switching back brings no result back; the code is kept; one undo step per switch, undo brings the result back, and none for the mode a cell already has; a cell without a result stays #BLOCKED!; the refusals (no Python cell, a bad or non-text mode, a bad cell, a table header, a locked cell, a non-text output for python.set), an unlocked Hidden cell on a protected sheet switches, and a recorded switch played back on a cell that already has the new type keeps it. --- crates/engine/src/cmd/extra/scripts.rs | 2 +- crates/engine/src/cmd/python.rs | 79 ++++++--- crates/engine/src/lib.rs | 2 +- crates/engine/src/record.rs | 14 +- crates/engine/src/tests/python_commands.rs | 154 ++++++++++++++++++ .../engine/tests/it/command_sequence_fuzz.rs | 9 + 6 files changed, 235 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/cmd/extra/scripts.rs b/crates/engine/src/cmd/extra/scripts.rs index b14fbdb23..e9b877672 100644 --- a/crates/engine/src/cmd/extra/scripts.rs +++ b/crates/engine/src/cmd/extra/scripts.rs @@ -138,7 +138,7 @@ pub(super) fn run_script(s: &mut Session, p: &Json) -> Result { let opened_dialog = s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::Dialog(..)))); let changed = wb_before.as_ref().is_some_and(|(uid, wb)| s.docs.iter().any(|d| d.uid == *uid && !std::sync::Arc::ptr_eq(&d.wb, wb))); if !opened_dialog || changed { - ran_steps.push((id.to_string(), crate::record::with_toggle_state(st["params"].clone(), &result))); + ran_steps.push((id.to_string(), crate::record::with_toggle_state(id, st["params"].clone(), &result))); } } } diff --git a/crates/engine/src/cmd/python.rs b/crates/engine/src/cmd/python.rs index 8e7007ea1..02d0ccb4e 100644 --- a/crates/engine/src/cmd/python.rs +++ b/crates/engine/src/cmd/python.rs @@ -1,6 +1,6 @@ -//! Python in cells: making and editing Python cells. This build stores the code and the last -//! result a file cached; it doesn't run Python, so a cell with new or changed code shows -//! `#BLOCKED!`. +//! Python in cells: making and editing Python cells, and their return type. This build stores +//! the code and the last result a file cached; it doesn't run Python, so a cell with new or +//! changed code shows `#BLOCKED!`. use std::sync::Arc; @@ -12,15 +12,26 @@ use super::{CommandSpec, bad, cell_param_on, cmd, edit, has_doc, str_param, targ use crate::{EngineError, Result, Session}; pub fn specs() -> Vec { - vec![cmd!( - "python.set", - "Set Python Code", - [], - None, - "{cell?, code, output?: object|value} makes the cell (default: the active cell) a Python cell with this code; the code must fit in Excel's 8,192-character formula once written as PY(…). `output` is the return type: `value` (an Excel value, the default) or `object` (a Python object)", - has_doc, - set - )] + vec![ + cmd!( + "python.set", + "Set Python Code", + [], + None, + "{cell?, code, output?: object|value} makes the cell (default: the active cell) a Python cell with this code; the code must fit in Excel's 8,192-character formula once written as PY(…). `output` is the return type: `value` (an Excel value, the default) or `object` (a Python object)", + has_doc, + set + ), + cmd!( + "python.output", + "Python Output", + [], + Some("Ctrl+Alt+Shift+M"), + "{cell?, mode?: object|value} sets the return type of a Python cell (default: the active cell): `object` (a Python object) or `value` (an Excel value). Without `mode` it switches to the other one. The cell keeps its code; a cached result of the old return type goes, as with `python.set`, so the cell shows #BLOCKED! until it runs", + has_doc, + output + ), + ] } fn target_cell(s: &Session, p: &Json) -> Result<(usize, CellRef)> { @@ -36,12 +47,17 @@ fn existing(s: &Session, sheet: usize, at: CellRef) -> Result> { Ok(s.doc()?.wb.sheet(sheet).and_then(|sh| sh.cell(at)).and_then(|c| c.py.as_deref()).cloned()) } -fn output_mode(p: &Json, key: &str) -> Result> { +fn output_mode(cmd: &str, p: &Json, key: &str) -> Result> { + match p.get(key) { + None | Some(Json::Null) => return Ok(None), + Some(Json::String(_)) => {} + Some(_) => return Err(bad(cmd, format!("`{key}` is `object` or `value`"))), + } match str_param(p, key) { None => Ok(None), Some(m) if m.eq_ignore_ascii_case("object") => Ok(Some(true)), Some(m) if m.eq_ignore_ascii_case("value") => Ok(Some(false)), - Some(m) => Err(bad("python.set", format!("`{key}` is `object` or `value`, not `{m}`"))), + Some(m) => Err(bad(cmd, format!("`{key}` is `object` or `value`, not `{m}`"))), } } @@ -52,13 +68,22 @@ fn set(s: &mut Session, p: &Json) -> Result { Some(_) => return Err(bad("python.set", "`code` must be text")), None => return Err(bad("python.set", "missing `code`")), }; - let mode = output_mode(p, "output")?; + let mode = output_mode("python.set", p, "output")?; let old = existing(s, sheet, at)?; let object = mode.or(old.as_ref().map(|o| o.object)).unwrap_or(false); - let mut py = PyCode { object, ..PyCode::new(code) }; + store(s, "python.set", sheet, at, PyCode { object, ..PyCode::new(code) }, old)?; + let d = s.doc()?; + let shown = d.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default(); + Ok(json!({"cell": at.a1(), "value": crate::cmd::inspect::value_json(&shown)})) +} + +/// Writes `py` into the cell, which held `old`: what `python.set` and `python.output` share. The +/// code must fit in one cell and a table header takes none; a locked cell on a protected sheet is +/// refused by the edit itself. +fn store(s: &mut Session, cmd: &str, sheet: usize, at: CellRef, mut py: PyCode, old: Option) -> Result<()> { if !py.fits() { return Err(bad( - "python.set", + cmd, format!("the code doesn't fit in one cell: written as PY(…) it would be longer than Excel's {MAX_FORMULA_CHARS}-character formula limit"), )); } @@ -89,8 +114,24 @@ fn set(s: &mut Session, p: &Json) -> Result { sh.set_cell(at, Cell { value, formula: None, style, py: Some(Arc::new(py)) }); cx.touch(sheet, at); Ok(()) - })?; + }) +} + +/// `python.output`: the cell's return type. The code stays; a cached result belongs to the old +/// return type, so it goes, as with `python.set` and a new return type: the cell shows +/// `#BLOCKED!` until it runs, and its old spill goes with its readers recalculated. +fn output(s: &mut Session, p: &Json) -> Result { + let (sheet, at) = target_cell(s, p)?; + let Some(old) = existing(s, sheet, at)? else { + return Err(EngineError::Other(format!("{} isn't a Python cell, so it has no Python output to change", at.a1()))); + }; + let object = output_mode("python.output", p, "mode")?.unwrap_or(!old.object); + let mode = |object: bool| if object { "object" } else { "value" }; + if object != old.object { + let py = PyCode { code: old.code.clone(), object, output: None, stale: false }; + store(s, "python.output", sheet, at, py, Some(old))?; + } let d = s.doc()?; let shown = d.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default(); - Ok(json!({"cell": at.a1(), "value": crate::cmd::inspect::value_json(&shown)})) + Ok(json!({"cell": at.a1(), "output": mode(object), "value": crate::cmd::inspect::value_json(&shown)})) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4ea043a68..d8b7c4408 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -792,7 +792,7 @@ impl Session { // A toggle (Bold) is recorded with the state it set, as Excel records // `Bold = True`: playing it back sets that state rather than flipping it again. let params = match &result { - Ok(r) if params.is_object() => record::with_toggle_state(cmd::journal_params(¶ms), r), + Ok(r) if params.is_object() => record::with_toggle_state(id, cmd::journal_params(¶ms), r), _ => cmd::journal_params(¶ms), }; let kept = rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params }); diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs index 269d11aed..a97082330 100644 --- a/crates/engine/src/record.rs +++ b/crates/engine/src/record.rs @@ -74,15 +74,21 @@ impl Default for Recorder { /// `params` with the state a toggle set (Bold's `on`, from the command's result), as Excel /// records `Bold = True`: playing the step back sets that state rather than flipping it again. -pub(crate) fn with_toggle_state(mut params: Json, result: &Json) -> Json { - if let Some(on @ Json::Bool(_)) = result.get("on") { +/// `python.output` without `mode` is a toggle too: it keeps the return type it set (the result's +/// `output`) as its `mode`. +pub(crate) fn with_toggle_state(id: &str, mut params: Json, result: &Json) -> Json { + let state = match id { + "python.output" => result.get("output").filter(|o| o.is_string()).map(|o| ("mode", o)), + _ => result.get("on").filter(|o| o.is_boolean()).map(|o| ("on", o)), + }; + if let Some((key, state)) = state { if params.is_null() { params = Json::Object(serde_json::Map::new()); } if let Json::Object(p) = &mut params - && !p.contains_key("on") + && p.get(key).is_none_or(Json::is_null) { - p.insert("on".into(), on.clone()); + p.insert(key.into(), state.clone()); } } params diff --git a/crates/engine/src/tests/python_commands.rs b/crates/engine/src/tests/python_commands.rs index 6d0bfac42..eb794c609 100644 --- a/crates/engine/src/tests/python_commands.rs +++ b/crates/engine/src/tests/python_commands.rs @@ -89,3 +89,157 @@ fn set_over_a_spilling_formula_takes_the_spill_away() { assert_eq!(v(&s, "A3"), Value::Empty); assert!(s.doc().unwrap().wb.sheet(0).unwrap().spill_ranges.is_empty()); } + +/// A cached result as a file would have kept it on the cell: `[[1], [2], [3]]`, a list. +fn cache_list(s: &mut crate::Session, cell: &str) { + let at = gridcraft_core::CellRef::parse(cell).unwrap(); + let d = s.doc_mut().unwrap(); + let wb = std::sync::Arc::make_mut(&mut d.wb); + let sh = wb.sheet_mut(0).unwrap(); + let py = std::sync::Arc::make_mut(sh.cells.get_mut(at).unwrap().py.as_mut().unwrap()); + let arr = gridcraft_core::Array::new(3, 1, vec![1.0.into(), 2.0.into(), 3.0.into()]).map(Value::from).unwrap(); + py.output = Some(gridcraft_model::PyOutput { value: arr, type_name: "list".into(), preview: "[1, 2, 3]".into(), convertible: true }); +} + +/// A1 holds `[1, 2, 3]` with its cached list spilled over A1:A3 as Excel values; B1 reads A3. +fn cached_spill_with_a_reader() -> crate::Session { + let mut s = s(); + set(&mut s, "A1", "[1, 2, 3]"); + cache_list(&mut s, "A1"); + s.execute("formulas.calculateNow", json!({})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A3)"})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(1.0)); + assert_eq!(v(&s, "B1"), Value::Number(3.0)); + s +} + +fn output_of(s: &mut crate::Session, cell: &str) -> serde_json::Value { + s.execute("cell.get", json!({"cell": cell})).unwrap()["python"]["output"].clone() +} + +fn undo_depth(s: &mut crate::Session) -> Option { + s.execute("history.list", json!({})).unwrap()["undo"].as_array().map(Vec::len) +} + +#[test] +fn output_switches_the_return_type_and_drops_a_result_of_the_other_type() { + let mut s = cached_spill_with_a_reader(); + // The mode it already has changes nothing and adds no undo step. + let undo = undo_depth(&mut s); + let r = s.execute("python.output", json!({"cell": "A1", "mode": "value"})).unwrap(); + assert_eq!(r["output"], "value"); + assert_eq!(undo_depth(&mut s), undo); + assert_eq!(v(&s, "A3"), Value::Number(3.0), "the kept result still spills"); + // Without `mode` it switches. The cached list is an Excel value's result: as with python.set + // and a new return type, the cell has no result until it runs, and its old spill goes with + // its readers recalculated. + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + let r = s.execute("python.output", json!({})).unwrap(); + assert_eq!(r["output"], "object"); + assert_eq!(v(&s, "A1"), Value::Error(CellError::Blocked)); + assert_eq!(v(&s, "A3"), Value::Empty, "the old spill goes"); + assert_eq!(v(&s, "B1"), Value::Number(0.0), "and what read it is recalculated"); + assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["code"], "[1, 2, 3]", "the code stays"); + // The same as python.set with the same code and the other return type. + let mut by_set = cached_spill_with_a_reader(); + by_set.execute("python.set", json!({"cell": "A1", "code": "[1, 2, 3]", "output": "object"})).unwrap(); + for c in ["A1", "A3", "B1"] { + assert_eq!(v(&s, c), v(&by_set, c), "{c}"); + } + assert_eq!(output_of(&mut s, "A1"), output_of(&mut by_set, "A1")); + // Switching back brings no result back: the dropped one belonged to the old type. + s.execute("python.output", json!({"cell": "A1", "mode": "value"})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Error(CellError::Blocked)); + assert_eq!(v(&s, "A3"), Value::Empty); + // Each switch is one undo step, and undoing the first brings the result and its spill back. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(output_of(&mut s, "A1"), "object"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(output_of(&mut s, "A1"), "value"); + assert_eq!(v(&s, "A1"), Value::Number(1.0)); + assert_eq!(v(&s, "A3"), Value::Number(3.0)); + assert_eq!(v(&s, "B1"), Value::Number(3.0)); + // A cell with no result keeps showing #BLOCKED!. + set(&mut s, "C1", "x = 1"); + s.execute("python.output", json!({"cell": "C1"})).unwrap(); + assert_eq!(v(&s, "C1"), Value::Error(CellError::Blocked)); + assert_eq!(output_of(&mut s, "C1"), "object"); +} + +#[test] +fn output_refuses_table_headers_and_locked_cells_as_set_does() { + let mut s = s(); + // A Python cell in a table's header row (as a file could hold one): python.set refuses to + // put code there, and python.output refuses to change it. + s.execute("range.setValues", json!({"range": "C1", "values": [["Name", "Score"], ["a", 1]]})).unwrap(); + s.execute("insert.table", json!({"range": "C1:D2", "headers": true})).unwrap(); + { + let d = s.doc_mut().unwrap(); + let wb = std::sync::Arc::make_mut(&mut d.wb); + let cell = wb.sheet_mut(0).unwrap().cells.get_mut(gridcraft_core::CellRef::parse("D1").unwrap()).unwrap(); + cell.py = Some(std::sync::Arc::new(gridcraft_model::PyCode::new("'Score'"))); + } + let e = s.execute("python.output", json!({"cell": "D1"})).unwrap_err(); + assert!(e.to_string().contains("table header"), "{e}"); + assert_eq!(output_of(&mut s, "D1"), "value"); + // A locked cell on a protected sheet refuses it; an unlocked one formatted Hidden takes it, + // as it takes python.set. + set(&mut s, "F1", "1"); + set(&mut s, "G1", "2"); + s.execute("home.formatCells", json!({"range": "G1", "style": {"protection": {"locked": false, "hidden": true}}})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + let e = s.execute("python.output", json!({"cell": "F1"})).unwrap_err(); + assert!(e.to_string().contains("protected"), "{e}"); + assert_eq!(output_of(&mut s, "F1"), "value"); + s.execute("python.set", json!({"cell": "G1", "code": "2"})).unwrap(); + s.execute("python.output", json!({"cell": "G1"})).unwrap(); + assert_eq!(output_of(&mut s, "G1"), "object"); +} + +#[test] +fn output_refuses_cells_without_python_and_bad_modes() { + let mut s = s(); + // Empty params: the active cell, which holds no Python code. + let e = s.execute("python.output", json!({})).unwrap_err(); + assert!(e.to_string().contains("isn't a Python cell"), "{e}"); + s.execute("cell.set", json!({"cell": "B1", "input": "=1+1"})).unwrap(); + assert!(s.execute("python.output", json!({"cell": "B1"})).is_err()); + set(&mut s, "A1", "1"); + for p in [json!({"cell": "A1", "mode": "sideways"}), json!({"cell": "A1", "mode": 1}), json!({"cell": "nowhere"})] { + assert!(s.execute("python.output", p.clone()).is_err(), "{p}"); + } + assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["output"], "value"); + // python.set refuses a return type that isn't text too, rather than ignoring it. + assert!(s.execute("python.set", json!({"cell": "A1", "code": "1", "output": true})).is_err()); +} + +#[test] +fn a_recorded_switch_keeps_the_return_type_it_set() { + // python.output without `mode` switches, so a recording keeps the return type the switch set + // (as Bold records `on`): played back on a cell that already has it, nothing switches back. + let mut s = s(); + set(&mut s, "A1", "1"); + s.execute("macro.record", json!({"relative": false})).unwrap(); + s.execute("python.output", json!({"cell": "A1"})).unwrap(); + s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap(); + assert_eq!(output_of(&mut s, "A1"), "object"); + let m = s.execute("macro.get", json!({"name": "Rec"})).unwrap(); + let m = crate::macros::parse_macro(&m).unwrap(); + let steps = crate::macros::command_steps(&m.steps); + let step = steps.iter().find(|(_, id, _)| *id == "python.output").map(|(_, _, p)| (*p).clone()); + assert_eq!(step.and_then(|p| p.get("mode").cloned()), Some(json!("object"))); + for _ in 0..2 { + s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap(); + assert_eq!(output_of(&mut s, "A1"), "object"); + } + // A run inside a script is recorded the same way. + s.execute("automate.newScript", json!({"name": "Switch", "commands": [{"command": "python.output", "params": {"cell": "A1"}}]})).unwrap(); + s.execute("macro.record", json!({"relative": false})).unwrap(); + s.execute("automate.runScript", json!({"name": "Switch"})).unwrap(); + s.execute("macro.record", json!({"stop": true, "name": "Rec2"})).unwrap(); + assert_eq!(output_of(&mut s, "A1"), "value"); + for _ in 0..2 { + s.execute("macro.run", json!({"name": "Rec2", "onConflict": "overwrite"})).unwrap(); + assert_eq!(output_of(&mut s, "A1"), "value"); + } +} diff --git a/crates/engine/tests/it/command_sequence_fuzz.rs b/crates/engine/tests/it/command_sequence_fuzz.rs index c32190dcd..89b3e9186 100644 --- a/crates/engine/tests/it/command_sequence_fuzz.rs +++ b/crates/engine/tests/it/command_sequence_fuzz.rs @@ -793,6 +793,7 @@ const CURATED: &[(&str, usize)] = &[ ("formulas.deleteName", 1), ("edit.replace", 1), ("python.set", 3), + ("python.output", 2), ]; fn generate(rng: &mut Rng, s: &Session) -> Step { @@ -845,6 +846,14 @@ fn curated_params(rng: &mut Rng, wb: &Workbook, id: &str) -> Json { } p } + "python.output" => { + // Mostly the cells python.set writes to; a missing mode switches. + let mut p = json!({"cell": near_cell(rng).a1()}); + if rng.chance(50) { + p["mode"] = json!(if rng.chance(50) { "object" } else { "value" }); + } + p + } "range.fill" => json!({"range": near_range(rng).a1(), "input": input(rng, wb)}), "selection.set" => { let mut p = json!({"range": range(rng)}); From 9ac06dd8cc42ee329f7f1b3d3a55f7bf9496df44 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:05:03 -0400 Subject: [PATCH 03/10] UI: Python mode in the formula bar Python cells get an editing mode of their own in the formula bar, in the code editor (monospace, Python colours). It starts with Ctrl+Alt+Shift+P (Cmd+Option+Shift+P on macOS), with =PY then Tab as in Excel, or by editing a Python cell (F2, a double-click, a click on the formula bar); typing over a Python cell still replaces it. The shortcut again leaves Python mode and keeps the text as an ordinary entry. In Python mode Enter starts a new line with the indentation, Tab and Shift+Tab indent, Ctrl+Enter (Cmd+Enter on macOS) commits the code through python.set and Esc cancels. Every commit path of a Python edit goes through python.set, the formula bar's Enter button and a click on the sheet included. When the command refuses the code (longer than a cell holds), its message shows and the edit stays open with the code as typed. A locked cell on a protected sheet is refused before a draft exists, and a draft whose cell protection locks while it is open is dropped with the warning, as an ordinary edit is (#2945). A Python cell's code committed unchanged writes nothing (no undo step), as F2 then Enter on any other cell does. With all the code deleted, the commit clears a Python cell through cell.set, as an empty entry clears any other cell (code of spaces is still code); on another cell it writes nothing. The formula bar shows while a Python edit is open, also when View > Formula Bar hides it, since Python mode edits only there. A Python draft records the window it was typed in, as an ordinary draft does, so it is committed in that window when another comes forward. Python mode replaces the route that sent a changed edit of a Python cell's code through python.set (EditState.code_edit and commit_code_edit): every such edit now starts in Python mode, so that route is removed, and leaving Python mode commits the text as an ordinary entry through cell.set. The UI tests of that route commit with Ctrl+Enter, since Enter starts a new line in Python mode; the guard that keeps a column completion out of a code edit now reads Python mode. The keys are applied in input order with the typing, on main's apply_in_order: the code editor takes a set of stop keys from its caller (apply_in_order_until), so Ctrl+Enter, Esc and the shortcut act on the text as it was when they were pressed. After a key the text field acts on (select all), a stop key waits for the next pass with the editor's keys before it, so select all, Tab and Ctrl+Enter in one frame commits the indented code. Keyboard events after a stop key in the same pass are dropped. code_field is the editor without its frame, for the bar. The PY badge beside fx shows in Python mode and on a selected Python cell; clicking it switches Python mode as the shortcut does. ui.pythonMode {on?} does the same from the control channel, without a dialog, and engine.commands lists it. ui.inspect reports editor.python, and leaves a Python draft's code out (text null, hidden true) while its cell's code is hidden on a protected sheet, checked when it is called: a protection command earlier in the same batch runs before the next frame. Theme tokens badge_py, on_badge, sheet_py_tag and on_sheet_py_tag join the contrast test. A draft, Python or not, records its sheet's identity (Sheet::uid) when it starts and follows that sheet, since commands that run while it is open (sheet.move, home.insertSheet, home.deleteSheet, their undo, a macro, a tool that adds a report sheet) change which sheet sits at its index. A commit goes to the draft's own sheet; before, a draft committed after a sheet was inserted before its own went into the inserted sheet, an ordinary draft as well. A draft whose sheet is gone is dropped with a message, as one whose cell protection locks is. ui.inspect checks for hidden code on the draft's own sheet, and once that sheet is gone it leaves out a Python draft, or one that began on a Python cell. --- crates/ui-egui/src/app.rs | 6 +- crates/ui-egui/src/app/commands.rs | 10 + crates/ui-egui/src/app/editing.rs | 207 ++++++++-- crates/ui-egui/src/code_editor.rs | 151 +++++++- crates/ui-egui/src/code_editor/tests.rs | 35 ++ crates/ui-egui/src/control.rs | 19 +- crates/ui-egui/src/control/tests.rs | 231 +++++++++++ crates/ui-egui/src/editor.rs | 105 ++++- crates/ui-egui/src/formula_bar.rs | 184 ++++++++- crates/ui-egui/src/keymap.rs | 5 + crates/ui-egui/src/theme.rs | 18 + crates/ui-egui/tests/it/main.rs | 2 + crates/ui-egui/tests/it/python_cell_edit.rs | 27 +- crates/ui-egui/tests/it/python_hidden.rs | 137 +++++++ crates/ui-egui/tests/it/python_mode.rs | 409 ++++++++++++++++++++ 15 files changed, 1463 insertions(+), 83 deletions(-) create mode 100644 crates/ui-egui/tests/it/python_hidden.rs create mode 100644 crates/ui-egui/tests/it/python_mode.rs diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 068be59a4..b25d63426 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -14,6 +14,8 @@ use super::{ mod clipboard; mod commands; mod editing; +#[cfg(test)] +pub(crate) use editing::{DRAFT_CODE_HIDDEN, DRAFT_SHEET_GONE}; mod files; mod fonts; mod input; @@ -221,7 +223,9 @@ impl SheetApp { } else { ribbon::show(self, ui); widgets::notice_bar(self, ui); - if self.session.prefs.formula_bar { + // Python mode edits only in the formula bar: it shows while a Python edit is open, + // also when the preference hides it. + if self.session.prefs.formula_bar || self.editor.as_ref().is_some_and(|e| e.python) { formula_bar::show(self, ui); } if self.ui.status_bar { diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index c7dcde0ff..b5e90e757 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -234,6 +234,15 @@ impl SheetApp { self.open_dialog(name, p.clone()); Ok(json!({"dialog": self.dialog.as_ref().map(|d| d.name())})) } + "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. + 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 { + self.toggle_python_mode(); + } + Ok(json!({"on": self.editor.as_ref().is_some_and(|e| e.python)})) + } "ui.message.dismiss" => { self.message = None; Ok(Json::Null) @@ -280,6 +289,7 @@ impl SheetApp { } } } + self.follow_open_draft(); } /// Whether the active sheet of the active document is protected (drives the Protect/Unprotect diff --git a/crates/ui-egui/src/app/editing.rs b/crates/ui-egui/src/app/editing.rs index 61b2af1ff..903e302ac 100644 --- a/crates/ui-egui/src/app/editing.rs +++ b/crates/ui-egui/src/app/editing.rs @@ -1,4 +1,10 @@ -use super::{Json, SheetApp, clean_error, editor, formula_bar, formula_locale, json, now_ms}; +use super::{SheetApp, clean_error, editor, formula_bar, formula_locale, json, now_ms}; + +/// Why a draft whose sheet was deleted while it was open is dropped. +pub(crate) const DRAFT_SHEET_GONE: &str = "The sheet of the cell being edited was deleted, so the entry was discarded."; + +/// Why a draft of Python code that sheet protection hid while it was open is dropped. +pub(crate) const DRAFT_CODE_HIDDEN: &str = "Sheet protection now hides the code of the cell being edited, so the entry was discarded."; impl SheetApp { /// Starts editing the active cell. `text` replaces the content (typing) or pre-fills it. @@ -28,6 +34,22 @@ impl SheetApp { if text.is_none() && sh.cell_pictures.contains_key(&at) { return; } + // Editing a Python cell edits its code, in Python mode; typing replaces it as in Excel. + if text.is_none() + && let Some(py) = sh.cell(at).and_then(|c| c.py.as_deref()) + { + // Code hidden on a protected sheet isn't loaded into an edit, and a blank edit would + // write over it on commit: no edit starts. Typing still replaces the cell. + if gridcraft_engine::display::formula_hidden(&d.wb, sh, at) { + return; + } + let mut ed = editor::EditState::python(d.uid, d.wb.active_sheet(), at, py.code.clone()).on_sheet(&d.wb); + ed.py_cell = true; + ed.window = self.session.active_window(); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + return; + } let locale = self.ui.language.formula_locale(); let current = if sh.cell_pictures.contains_key(&at) { String::new() @@ -41,8 +63,7 @@ impl SheetApp { Some(t) => (t, true), None => (current.clone(), false), }; - let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet(), at, text, replace, from_formula_bar); - ed.code_edit = !replace && sh.cell(at).is_some_and(|c| c.py.is_some()); + let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet(), at, text, replace, from_formula_bar).on_sheet(&d.wb); ed.window = self.session.active_window(); ed.locale = locale; if replace && self.ui.autocomplete && !ed.is_formula() { @@ -97,15 +118,27 @@ impl SheetApp { } fn commit_edit_here(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { - let Some(ed) = self.editor.take() else { return true }; + // Python code goes in through `python.set`, whichever way the edit is committed (the + // formula bar's Enter button, a click on the sheet). + if self.editor.as_ref().is_some_and(|e| e.python) { + let ok = self.commit_python_edit(); + if ok && (dr != 0 || dc != 0) { + self.move_after_enter(dr, dc); + } + return ok; + } + let Some(mut ed) = self.editor.take() else { return true }; self.session.mode = gridcraft_engine::Mode::Ready; + if !self.follow_draft_sheet(&mut ed) { + return true; + } let text = match &ed.completion { // A code edit commits the code as typed, whatever a completion holds. - Some(full) if !ed.code_edit && full.to_lowercase().starts_with(&ed.text.to_lowercase()) => full.clone(), + Some(full) if !ed.python && full.to_lowercase().starts_with(&ed.text.to_lowercase()) => full.clone(), _ => ed.text.clone(), }; // F2 then Enter (or a file command) on an untouched draft: nothing to write. - if (ed.code_edit || (!fill_selection && !array)) && ed.original.as_ref() == Some(&text) { + if !fill_selection && !array && ed.original.as_ref() == Some(&text) { if dr != 0 || dc != 0 { self.move_after_enter(dr, dc); } @@ -118,11 +151,6 @@ impl SheetApp { self.message = Some(("GridCraft".into(), m)); return true; } - // All the code deleted: the cell is cleared through cell.set, as an empty entry clears any - // other cell. Code of spaces is still code. - if ed.code_edit && !text.is_empty() { - return self.commit_code_edit(ed, text, dr, dc); - } let text = match self.session.active().map(|d| formula_locale::canonical(&text, ed.locale, &d.wb, ed.sheet, ed.cell)) { Some(Ok(text)) => text, Some(Err(error)) => { @@ -187,29 +215,55 @@ impl SheetApp { } } - /// Commits a changed edit of a Python cell's code through `python.set`, which keeps the cell's - /// return type; `cell.set` would replace the Python cell with an ordinary one holding the code - /// as text. When the command refuses the code, its message shows and the edit stays open. - fn commit_code_edit(&mut self, ed: editor::EditState, code: String, dr: i64, dc: i64) -> bool { - // Point mode can leave another sheet active; the edit belongs to the sheet it started on. - let r = if self.session.active().is_some_and(|d| d.wb.active_sheet() != ed.sheet) { - self.session.run("sheet.activate", json!({"sheet": ed.sheet})) - } else { - Ok(Json::Null) + /// After a command, keeps the open draft on its own sheet: a sheet inserted, deleted or moved + /// before it (an undo or a macro too) changes which sheet sits at its index. The draft is then + /// drawn, pointed from and committed on its sheet, and the point-mode anchor, held by sheet + /// index, is let go. A draft is dropped at once with a message when its sheet is gone, or when + /// it holds Python code that sheet protection now hides (a draft in Python mode, or one that + /// began on a Python cell): neither editor shows hidden code, as the Python editor window + /// closes then too. + pub(crate) fn follow_open_draft(&mut self) { + let Some(ed) = self.editor.as_ref() else { return }; + let Some(d) = self.session.documents().iter().find(|d| d.uid == ed.doc) else { return }; + let order = editor::sheet_order(&d.wb); + let moved = ed.sheet_uid != 0 && order != ed.sheet_order; + let found = if moved { ed.sheet_in(&d.wb) } else { Some(ed.sheet) }; + let hidden = found.and_then(|i| d.wb.sheet(i)).is_some_and(|sh| { + (ed.python || ed.py_cell || sh.cell(ed.cell).is_some_and(|c| c.py.is_some())) + && gridcraft_engine::display::formula_hidden(&d.wb, sh, ed.cell) + }); + let why = match found { + None => Some(DRAFT_SHEET_GONE), + Some(_) if hidden => Some(DRAFT_CODE_HIDDEN), + Some(_) => None, }; - let r = r.and_then(|_| self.session.run("python.set", json!({"sheet": ed.sheet, "cell": ed.cell.a1(), "code": code}))); - match r { - Ok(_) => { - if dr != 0 || dc != 0 { - self.move_after_enter(dr, dc); - } - self.after_engine(); + if let Some(why) = why { + self.editor = None; + self.session.mode = gridcraft_engine::Mode::Ready; + self.message = Some(("GridCraft".into(), why.into())); + return; + } + if let (true, Some(sheet), Some(ed)) = (moved, found, self.editor.as_mut()) { + ed.sheet = sheet; + ed.sheet_order = order; + ed.point_anchor = None; + } + } + + /// Points a draft being committed at its sheet, found by identity: commands (a sheet inserted + /// or deleted before it, a move, an undo, a macro) change which sheet sits at its index while + /// it is open. A draft whose sheet is gone is dropped with a message, as one whose cell + /// protection locks is, rather than being entered on whichever sheet took its place; `false` + /// then. + fn follow_draft_sheet(&mut self, ed: &mut editor::EditState) -> bool { + let Some(d) = self.session.active() else { return true }; + match ed.sheet_in(&d.wb) { + Some(sheet) => { + ed.sheet = sheet; true } - Err(e) => { - self.message = Some(("GridCraft".into(), clean_error(&e))); - self.editor = Some(ed); - self.session.mode = gridcraft_engine::Mode::Edit; + None => { + self.message = Some(("GridCraft".into(), DRAFT_SHEET_GONE.into())); false } } @@ -229,6 +283,97 @@ impl SheetApp { self.session.mode = gridcraft_engine::Mode::Ready; } + /// Starts editing the active cell in Python mode, in the formula bar: its code, or no code + /// yet for a cell that isn't a Python cell. A cell inside a spill isn't edited, as with the + /// formula bar's other entry paths. + pub fn begin_python_edit(&mut self) { + if formula_bar::active_spill_anchor(self).is_some() { + return; + } + if let Err(e) = self.commit_text_box_edit() { + self.message = Some(("Text Box".into(), clean_error(&e))); + return; + } + let Some(d) = self.session.active() else { return }; + let Some(sh) = d.wb.active() else { return }; + let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active); + // A locked cell on a protected sheet is refused before a draft exists, as `begin_edit` + // refuses it (#2945). + if let Some(m) = self.session.locked_cell_message(d.wb.active_sheet(), at) { + self.message = Some(("GridCraft".into(), m)); + return; + } + let py = sh.cell(at).and_then(|c| c.py.as_deref()); + // A Python cell's code hidden on a protected sheet stays out of view, and Python mode + // doesn't start on it with no code, which a commit would write over the hidden code. + if py.is_some() && gridcraft_engine::display::formula_hidden(&d.wb, sh, at) { + return; + } + let code = py.map(|py| py.code.clone()).unwrap_or_default(); + // The draft belongs to this window, as `begin_edit`'s do: switching windows commits it here. + let mut ed = editor::EditState::python(d.uid, d.wb.active_sheet(), at, code).on_sheet(&d.wb); + ed.py_cell = py.is_some(); + ed.window = self.session.active_window(); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + } + + /// Ctrl+Alt+Shift+P (and the PY badge): Python mode on or off. An edit in progress keeps its + /// text; with no edit, Python mode starts on the active cell. + pub fn toggle_python_mode(&mut self) { + match self.editor.as_mut() { + Some(ed) if ed.python => ed.leave_python(), + Some(ed) => ed.enter_python(), + None => return self.begin_python_edit(), + } + self.session.mode = gridcraft_engine::Mode::Edit; + } + + /// 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 + /// typed. + pub fn commit_python_edit(&mut self) -> bool { + let Some(mut ed) = self.editor.take() else { return true }; + self.session.mode = gridcraft_engine::Mode::Ready; + if !self.follow_draft_sheet(&mut ed) { + return true; + } + // The code of a Python cell committed unchanged: nothing to write (no undo step, the + // workbook stays clean), as with F2 then Enter on any other cell. + let code = gridcraft_engine::model::python::normalize_code(&ed.text); + let old = self.session.active().and_then(|d| d.wb.sheet(ed.sheet)).and_then(|sh| sh.cell(ed.cell)).and_then(|c| c.py.as_deref()); + let python_cell = old.is_some(); + if old.is_some_and(|py| py.code == code) { + return true; + } + // A draft on a cell sheet protection now locks can never be entered: it is dropped with + // the warning, as `commit_edit_here` drops an ordinary one (#2945). + if let Some(m) = self.session.locked_cell_message(ed.sheet, ed.cell) { + self.message = Some(("GridCraft".into(), m)); + return true; + } + // No code at all: a Python cell is cleared through cell.set, as an empty entry clears any + // other cell (code of spaces is still code). On another cell there is nothing to write. + let r = match (ed.text.is_empty(), python_cell) { + (true, false) => return true, + (true, true) => self.session.run("cell.set", json!({"sheet": ed.sheet, "cell": ed.cell.a1(), "input": ""})), + (false, _) => self.session.run("python.set", json!({"sheet": ed.sheet, "cell": ed.cell.a1(), "code": ed.text})), + }; + match r { + Ok(_) => { + self.after_engine(); + true + } + Err(e) => { + self.message = Some(("GridCraft".into(), clean_error(&e))); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + false + } + } + } + /// Enter/Tab move inside a multi-cell selection, otherwise to the next cell. pub fn move_after_enter(&mut self, dr: i64, dc: i64) { let multi = self.session.active().is_some_and(|d| !d.selection.is_single_cell()); diff --git a/crates/ui-egui/src/code_editor.rs b/crates/ui-egui/src/code_editor.rs index ee65b7ec8..f96e3b575 100644 --- a/crates/ui-egui/src/code_editor.rs +++ b/crates/ui-egui/src/code_editor.rs @@ -371,6 +371,8 @@ pub struct Applied { /// The keyboard events held back for the next pass, in order: the editor's first key after /// an event the text field acts on, and every keyboard event after that key. pub held: Vec, + /// The caller's key the pass stopped at (see [`apply_in_order_until`]). + pub stop: Option, } /// Applies the pass's input to `text` in the order it came. A pass can carry several events @@ -387,6 +389,22 @@ pub struct Applied { /// follows each one, not the key. `sel` is the selection as char indices (anchor, caret), /// updated. pub fn apply_in_order(events: &mut Vec, text: &mut String, sel: &mut (usize, usize), lang: Lang) -> Applied { + apply_in_order_until(events, text, sel, lang, &|_| false) +} + +/// [`apply_in_order`], stopping at the first event `stop` picks: a key the caller acts on itself +/// (commit, cancel, a mode switch). It counts as one of the editor's keys for the order: the +/// typing before it is applied first, and after an event the text field acts on it is held back +/// for the next pass with the rest. Once the pass stops (`Applied::stop`), the keyboard events +/// after the stop key are dropped: the caller acts on the text as it was when the key was +/// pressed, and what followed it was typed after it. +pub fn apply_in_order_until( + events: &mut Vec, + text: &mut String, + sel: &mut (usize, usize), + lang: Lang, + stop: &dyn Fn(&egui::Event) -> bool, +) -> Applied { let mut out = Applied::default(); // An event the field acts on has gone by: the editor's keys after it wait for the next pass. let mut field_seen = false; @@ -394,10 +412,25 @@ pub fn apply_in_order(events: &mut Vec, text: &mut String, sel: &mu let mut holding = false; let mut keep = Vec::with_capacity(events.len()); for ev in std::mem::take(events) { + if out.stop.is_some() { + if !is_keyboard(&ev) { + keep.push(ev); + } + continue; + } if holding && is_keyboard(&ev) { out.held.push(ev); continue; } + if is_keyboard(&ev) && stop(&ev) { + if field_seen { + holding = true; + out.held.push(ev); + } else { + out.stop = Some(ev); + } + continue; + } match classify(&ev) { Class::Edit(_) if field_seen => { holding = true; @@ -502,18 +535,88 @@ pub fn indent(text: &mut String, sel: (usize, usize), dedent: bool) -> Edit { /// caret. pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max_lines: usize) -> Response { let t = Tokens::get(ui.ctx()); - let font = font(); - let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font)); + let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font())); let max_lines = max_lines.clamp(1, 200); + let mut response = None; + egui::Frame::new().fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).corner_radius(4.0).inner_margin(4.0).show(ui, |ui| { + let options = FieldOptions { max_height: row_h * max_lines as f32, ..FieldOptions::new(lang) }; + response = Some(code_field(ui, id, text, &options).response); + }); + response.unwrap_or_else(|| ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover())) +} + +/// How a [`code_field`] is laid out, and the keys its caller takes. +pub struct FieldOptions<'a> { + pub lang: Lang, + /// The field's width (`f32::INFINITY`: all there is). + pub width: f32, + /// The height shown before it scrolls; the scroll area follows the caret. + pub max_height: f32, + /// Whether it takes input. A dialog or message box over the field has the keyboard. + pub interactive: bool, + /// Keys the caller acts on itself, in input order (see [`apply_in_order_until`]). + pub stop: &'a dyn Fn(&egui::Event) -> bool, + /// Esc stays with the field (for a caller that stops at it) instead of releasing the focus. + pub keep_escape: bool, + /// Puts the caret here (a char index) before the pass's input: the caller changed the text. + pub caret: Option, + /// The field's name for screen readers. + pub name: &'a str, +} + +impl FieldOptions<'_> { + pub fn new(lang: Lang) -> FieldOptions<'static> { + FieldOptions { + lang, + width: f32::INFINITY, + max_height: 100.0, + interactive: true, + stop: &|_| false, + keep_escape: false, + caret: None, + // The editor is a generic code field, so the name is not part of the UI's translated + // strings unless the caller gives one. + name: "Code Editor", + } + } +} + +/// What a [`code_field`] laid out and did. +pub struct FieldOutput { + pub response: Response, + /// The caller's key the pass stopped at, if any. + pub stop: Option, + /// Laid-out rows of the text. + pub lines: usize, + /// The caret on screen, while the field has the focus. + pub caret: Option, + /// The caret and the other end of the selection (char indices). + pub selection: (usize, usize), +} + +/// The code editor's text field without a frame: monospace text with syntax colours, Enter that +/// keeps the indentation, Tab and Shift+Tab, applied in input order (see [`apply_in_order`]). +pub fn code_field(ui: &mut Ui, id: egui::Id, text: &mut String, options: &FieldOptions<'_>) -> FieldOutput { + let t = Tokens::get(ui.ctx()); + let font = font(); + let lang = options.lang; // The input the editor handles itself (Enter with indentation, Tab, Shift+Tab), applied in // input order with the typing around it, before the text field sees the rest; what had to // wait for the field in the previous pass comes first. - let focused = ui.memory(|m| m.has_focus(id)); + let focused = options.interactive && ui.memory(|m| m.has_focus(id)); let held = take_held(ui, id, focused); let mut edited = None; + let mut stop = None; + let count = text.chars().count(); + if let Some(caret) = options.caret { + let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + let caret = caret.min(count); + state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(caret)))); + state.store(ui.ctx(), id); + edited = Some(Edit { anchor: caret, caret }); + } if focused { let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); - let count = text.chars().count(); let mut sel = state.cursor.char_range().map(|r| (r.secondary.index.into(), r.primary.index.into())).unwrap_or((count, count)); let applied = ui.input_mut(|i| { if !held.is_empty() { @@ -521,7 +624,7 @@ pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max events.append(&mut i.events); i.events = events; } - apply_in_order(&mut i.events, text, &mut sel, lang) + apply_in_order_until(&mut i.events, text, &mut sel, lang, options.stop) }); if applied.edited { state.cursor.set_char_range(Some(CCursorRange::two(CCursor::new(sel.0), CCursor::new(sel.1)))); @@ -531,6 +634,7 @@ pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max if !applied.held.is_empty() { hold(ui, id, applied.held); } + stop = applied.stop; } let syntax = t.syntax; let plain = t.text; @@ -538,35 +642,40 @@ pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max let job = layout_job(buf.as_str(), lang, &self::font(), plain, &syntax, wrap_width); ui.fonts_mut(|f| f.layout_job(job)) }; - let frame_h = row_h * max_lines as f32 + 8.0; - let mut response = None; - egui::Frame::new().fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).corner_radius(4.0).inner_margin(4.0).show(ui, |ui| { - egui::ScrollArea::vertical().id_salt(id.with("scroll")).max_height(frame_h - 8.0).auto_shrink([false, true]).show(ui, |ui| { + let filter = egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: options.keep_escape }; + let scrolled = + egui::ScrollArea::vertical().id_salt(id.with("scroll")).max_height(options.max_height.max(1.0)).auto_shrink([false, true]).show(ui, |ui| { let out = TextEdit::multiline(text) .id(id) .font(font.clone()) .code_editor() .frame(egui::Frame::NONE) - .desired_width(f32::INFINITY) + .desired_width(options.width) .desired_rows(1) - .lock_focus(true) + .interactive(options.interactive) + .event_filter(filter) .layouter(&mut layouter) .show(ui); if let Some(e) = edited { let caret = out.galley.pos_from_cursor(CCursor::new(e.caret)).translate(out.galley_pos.to_vec2()); ui.scroll_to_rect(caret, None); } - // The field has no visible label; name it for screen readers and the strict - // kittest accessibility check. The editor is a generic code field, so the name is - // not part of the UI's translated strings. - let mut r = out.response.response.accessible_name("Code Editor"); - if edited.is_some() { - r.mark_changed(); - } - response = Some(r); + out }); - }); - response.unwrap_or_else(|| ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover())) + let out = scrolled.inner; + // The field has no visible label; name it for screen readers and the strict kittest + // accessibility check. + let mut response = out.response.response.accessible_name(options.name); + if edited.is_some() { + response.mark_changed(); + } + let selection = out.cursor_range.map(|r| (r.primary.index.into(), r.secondary.index.into())).unwrap_or((count, count)); + let caret = out + .cursor_range + .map(|r| out.galley.pos_from_cursor(r.primary).translate(out.galley_pos.to_vec2())) + .filter(|_| response.has_focus()) + .map(|r| r.intersect(scrolled.inner_rect.expand(1.0))); + FieldOutput { response, stop, lines: out.galley.rows.len().max(1), caret, selection } } #[cfg(test)] diff --git a/crates/ui-egui/src/code_editor/tests.rs b/crates/ui-egui/src/code_editor/tests.rs index ba3c08efa..0a1fc3bb1 100644 --- a/crates/ui-egui/src/code_editor/tests.rs +++ b/crates/ui-egui/src/code_editor/tests.rs @@ -278,6 +278,41 @@ fn tab_and_enter_under_modifiers_never_reach_the_field() { assert!(out.held.is_empty()); } +#[test] +fn a_stop_key_ends_the_pass_in_input_order() { + let stop = |ev: &egui::Event| matches!(ev, egui::Event::Key { key: Key::Enter, pressed: true, modifiers, .. } if modifiers.command); + // The typing before it is applied; the keys after it are dropped, other input stays. + let mut events = vec![ + egui::Event::Text("a".into()), + key(Key::Enter, Modifiers::COMMAND), + egui::Event::Text("b".into()), + egui::Event::PointerMoved(egui::Pos2::ZERO), + key(Key::Tab, Modifiers::NONE), + ]; + let (mut s, mut sel) = (String::new(), (0, 0)); + let out = apply_in_order_until(&mut events, &mut s, &mut sel, Lang::Python, &stop); + assert_eq!(s, "a"); + assert_eq!(out.stop, Some(key(Key::Enter, Modifiers::COMMAND))); + assert_eq!(events, vec![egui::Event::PointerMoved(egui::Pos2::ZERO)]); + assert!(out.held.is_empty()); + // After a key the field acts on, it waits for the next pass with the editor's keys + // before it, so the edit it ends includes them. + let mut events = vec![key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE), key(Key::Enter, Modifiers::COMMAND)]; + let out = apply_in_order_until(&mut events, &mut s, &mut sel, Lang::Python, &stop); + assert_eq!(out.stop, None); + assert_eq!(events, vec![key(Key::A, Modifiers::COMMAND)]); + assert_eq!(out.held, vec![key(Key::Tab, Modifiers::NONE), key(Key::Enter, Modifiers::COMMAND)]); + // Next pass: the Tab, then the stop. + let mut events = out.held; + let out = apply_in_order_until(&mut events, &mut s, &mut sel, Lang::Python, &stop); + assert_eq!(s, "a "); + assert_eq!(out.stop, Some(key(Key::Enter, Modifiers::COMMAND))); + // Without a stop the editor behaves as before: the caller's key is the field's. + let mut events = vec![key(Key::Enter, Modifiers::COMMAND)]; + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert_eq!((out.stop, events.len()), (None, 1)); +} + #[test] fn events_after_a_held_key_wait_with_it_and_pointer_events_do_not() { let mut events = vec![ diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index af5eda156..d7919e60a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -145,7 +145,7 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json { "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| json!({"cell": e.cell.a1(), "text": e.text, "formulaBar": e.from_formula_bar, "point": e.can_point()})), + "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})), @@ -159,6 +159,22 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json { }) } +/// 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 any +/// draft of a Python cell, since leaving Python mode keeps the code as an ordinary draft. 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 = |sh: &gridcraft_engine::model::Sheet| e.python || sh.cell(e.cell).is_some_and(|c| c.py.is_some()); + let hidden = 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) => code(sh) && gridcraft_engine::display::formula_hidden(&d.wb, sh, e.cell), + None => e.python || e.py_cell, + }); + 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}) +} + /// Keeps the commands whose id, label or ribbon path contains `search` (case-insensitive); `None` keeps all. fn filter_commands(commands: Vec, search: Option<&str>) -> Vec { let Some(q) = search.map(str::to_ascii_lowercase) else { return commands }; @@ -192,6 +208,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}"), ("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 a680279e5..1183535e8 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -977,3 +977,234 @@ fn synthetic_text_key_and_move_producers_honour_the_queue_budget() { assert_eq!(reply["ok"], false, "{reply}"); assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), MAX_QUEUED_SYNTHETIC_EVENTS); } + +#[test] +fn python_ui_commands_are_listed() { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, crate::Services::default()); + let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search": "python"})) else { + 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}")]; + 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}"); + assert_eq!(command["params"], docs, "{id}"); + } +} + +/// Runs `command` through the control channel, as one line of a batch: no frame runs after it. +fn execute(app: &mut SheetApp, ctx: &egui::Context, command: &str, params: Json) { + let Outcome::Done(reply) = handle(app, ctx, "engine.execute", &json!({"command": command, "params": params})) else { + panic!("{command} answers at once"); + }; + assert_eq!(reply["ok"], true, "{command}: {reply}"); +} + +fn inspected(app: &mut SheetApp, ctx: &egui::Context) -> Json { + let Outcome::Done(reply) = handle(app, ctx, "ui.inspect", &json!({})) else { panic!("ui.inspect answers at once") }; + reply["result"].clone() +} + +#[test] +fn a_python_draft_on_code_hidden_in_the_same_batch_shows_no_code() { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + session.execute("python.set", json!({"cell": "B2", "code": "secret = 41\nsecret + 1"})).unwrap(); + session.execute("home.formatCells", json!({"range": "B2", "style": {"protection": {"hidden": true}}})).unwrap(); + session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + let mut app = SheetApp::new(session, crate::Services::default()); + let ctx = egui::Context::default(); + execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true})); + let before = inspected(&mut app, &ctx); + assert_eq!(before["editor"]["text"], "secret = 41\nsecret + 1", "unprotected, the draft shows its code"); + // Protected on the session itself, so no app command drops the draft first: `ui.inspect` + // still leaves the code out, checked when it is called. + app.session.execute("review.protectSheet", json!({})).unwrap(); + let after = inspected(&mut app, &ctx); + assert!(app.editor.as_ref().is_some_and(|e| e.python), "the draft is still open"); + assert!(!after.to_string().contains("secret"), "{after}"); + assert_eq!(after["editor"]["text"], Json::Null); + assert_eq!(after["editor"]["hidden"], true); + // Out of Python mode the draft still holds the hidden cell's code. + if let Some(e) = app.editor.as_mut() { + e.leave_python(); + } + assert!(app.editor.as_ref().is_some_and(|e| !e.python && e.text.contains("secret")), "an ordinary draft holds the code"); + let left = inspected(&mut app, &ctx); + assert!(!left.to_string().contains("secret"), "{left}"); + assert_eq!(left["editor"]["hidden"], true); +} + +/// Answers one control request that must answer at once with `ok`. +fn call(app: &mut SheetApp, ctx: &egui::Context, method: &str, params: Json) { + let Outcome::Done(reply) = handle(app, ctx, method, ¶ms) else { panic!("{method} answers at once") }; + assert_eq!(reply["ok"], true, "{method}: {reply}"); +} + +/// A Python draft of hidden code at B2 on Sheet1, protected after the draft opened, with an +/// unprotected sheet `Other` after it when `other` is set. +fn hidden_python_draft(other: bool) -> (SheetApp, egui::Context) { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + session.execute("python.set", json!({"cell": "B2", "code": "secret = 41\nsecret + 1"})).unwrap(); + session.execute("home.formatCells", json!({"range": "B2", "style": {"protection": {"hidden": true}}})).unwrap(); + if other { + session.execute("home.insertSheet", json!({"name": "Other", "before": 1})).unwrap(); + session.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + } + session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + let mut app = SheetApp::new(session, crate::Services::default()); + let ctx = egui::Context::default(); + execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true})); + // On the session itself, so the draft stays open for `ui.inspect` to be checked against. + app.session.execute("review.protectSheet", json!({})).unwrap(); + assert_eq!(inspected(&mut app, &ctx)["editor"]["hidden"], true, "hidden on its own sheet"); + (app, ctx) +} + +#[test] +fn a_python_draft_shows_no_code_when_another_sheet_takes_its_index_in_the_same_batch() { + let (mut app, ctx) = hidden_python_draft(false); + // An unprotected sheet now sits at the draft's old index, in the same batch. + app.session.execute("home.insertSheet", json!({"name": "New", "before": 0})).unwrap(); + let after = inspected(&mut app, &ctx); + assert!(!after.to_string().contains("secret"), "{after}"); + assert_eq!(after["editor"]["text"], Json::Null); + assert_eq!(after["editor"]["hidden"], true); +} + +#[test] +fn a_python_draft_shows_no_code_once_its_sheet_is_deleted_in_the_same_batch() { + let (mut app, ctx) = hidden_python_draft(true); + // The draft's own sheet goes, and the unprotected `Other` takes its index. + app.session.execute("home.deleteSheet", json!({"sheet": "Sheet1"})).unwrap(); + assert_eq!(app.session.active().map(|d| d.wb.sheets.len()), Some(1)); + let after = inspected(&mut app, &ctx); + assert!(!after.to_string().contains("secret"), "{after}"); + assert_eq!(after["editor"]["hidden"], true); + // The next app command (an undo that brings the protected sheet back) drops the draft, since + // its code is hidden there; deleting a draft's sheet is covered by + // `a_draft_whose_sheet_is_deleted_is_dropped_with_a_message`. + execute(&mut app, &ctx, "edit.undo", json!({})); + assert!(app.editor.is_none(), "the draft is dropped"); + assert_eq!(app.message.as_ref().map(|m| m.1.as_str()), Some(crate::app::DRAFT_CODE_HIDDEN)); + assert_eq!(inspected(&mut app, &ctx)["editor"], Json::Null); +} + +/// Sheet `name`'s A1: its input text and whether it is a Python cell. +fn a1(app: &SheetApp, name: &str) -> Option<(String, bool)> { + let d = app.session.active()?; + let sh = d.wb.sheet(d.wb.sheet_index(name)?)?; + sh.cell(gridcraft_engine::core::CellRef::new(0, 0)).map(|c| (c.input_text(), c.py.is_some())) +} + +#[test] +fn a_draft_is_committed_on_its_own_sheet_after_a_sheet_is_inserted_before_it() { + // An ordinary draft and a Python draft on Sheet1!A1, then a sheet inserted before Sheet1. + for python in [false, true] { + let mut app = test_app(); + let ctx = egui::Context::default(); + if python { + execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true})); + call(&mut app, &ctx, "ui.edit.text", json!({"text": "1 + 1"})); + } else { + call(&mut app, &ctx, "ui.edit.begin", json!({"text": "42"})); + } + execute(&mut app, &ctx, "home.insertSheet", json!({"name": "New", "before": 0})); + call(&mut app, &ctx, "ui.edit.commit", json!({"move": "none"})); + assert!(app.editor.is_none(), "python {python}"); + assert_eq!(a1(&app, "New"), None, "python {python}: nothing lands on the inserted sheet"); + let got = a1(&app, "Sheet1"); + if python { + assert_eq!(got.map(|g| g.1), Some(true), "the code lands on the draft's own sheet"); + } else { + assert_eq!(got, Some(("42".to_string(), false)), "the entry lands on the draft's own sheet"); + } + } +} + +#[test] +fn a_draft_whose_sheet_is_deleted_is_dropped_with_a_message() { + for python in [false, true] { + let mut app = test_app(); + let ctx = egui::Context::default(); + execute(&mut app, &ctx, "home.insertSheet", json!({"name": "Other", "before": 1})); + execute(&mut app, &ctx, "sheet.activate", json!({"sheet": 0})); + if python { + execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true})); + call(&mut app, &ctx, "ui.edit.text", json!({"text": "1 + 1"})); + } else { + call(&mut app, &ctx, "ui.edit.begin", json!({"text": "42"})); + } + execute(&mut app, &ctx, "home.deleteSheet", json!({"sheet": "Sheet1"})); + // Dropped once the command is done, not left drawn on the sheet in its place. + assert!(app.editor.is_none(), "python {python}: the draft is dropped"); + assert_eq!(inspected(&mut app, &ctx)["editor"], Json::Null, "python {python}"); + assert_eq!(a1(&app, "Other"), None, "python {python}: nothing lands on the sheet in its place"); + assert_eq!(app.message.as_ref().map(|m| m.1.as_str()), Some(crate::app::DRAFT_SHEET_GONE), "python {python}"); + } +} + +#[test] +fn an_open_draft_follows_its_sheet_when_a_sheet_is_inserted_before_it() { + for python in [false, true] { + let mut app = test_app(); + let ctx = egui::Context::default(); + if python { + execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true})); + } else { + call(&mut app, &ctx, "ui.edit.begin", json!({"text": "=SUM("})); + } + if let Some(ed) = app.editor.as_mut() { + ed.point_anchor = Some((0, gridcraft_engine::core::CellRef::parse("C3").unwrap())); + } + execute(&mut app, &ctx, "home.insertSheet", json!({"name": "Before", "before": 0})); + let ed = app.editor.as_ref().expect("the draft stays open"); + assert_eq!(ed.sheet, 1, "python {python}: the draft is on Sheet1, now at index 1, so it is drawn there"); + assert_eq!(ed.point_anchor, None, "python {python}: the anchor's sheet index no longer names its sheet"); + // A rename changes no index: nothing is let go. + if let Some(ed) = app.editor.as_mut() { + ed.point_anchor = Some((1, gridcraft_engine::core::CellRef::parse("C3").unwrap())); + } + execute(&mut app, &ctx, "sheet.rename", json!({"sheet": "Before", "name": "First"})); + assert!(app.editor.as_ref().is_some_and(|e| e.sheet == 1 && e.point_anchor.is_some()), "python {python}"); + } +} + +#[test] +fn a_draft_of_python_code_that_protection_hides_is_dropped_with_a_message() { + // In Python mode, and after leaving it (an ordinary draft of a Python cell's code). + for leave in [false, true] { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + session.execute("python.set", json!({"cell": "B2", "code": "secret = 41"})).unwrap(); + session.execute("cell.set", json!({"cell": "C2", "input": "=1+1"})).unwrap(); + session.execute("home.formatCells", json!({"range": "B2:C2", "style": {"protection": {"hidden": true, "locked": false}}})).unwrap(); + session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + let mut app = SheetApp::new(session, crate::Services::default()); + let ctx = egui::Context::default(); + execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true})); + if leave { + execute(&mut app, &ctx, "ui.pythonMode", json!({"on": false})); + assert!(app.editor.as_ref().is_some_and(|e| !e.python && e.text.contains("secret")), "an ordinary draft of the code"); + } + execute(&mut app, &ctx, "review.protectSheet", json!({})); + assert!(app.editor.is_none(), "leave {leave}: neither editor keeps hidden code on screen"); + assert_eq!(app.message.as_ref().map(|m| m.1.as_str()), Some(crate::app::DRAFT_CODE_HIDDEN), "leave {leave}"); + assert!(!inspected(&mut app, &ctx).to_string().contains("secret")); + } + // An ordinary formula draft on a hidden cell stays open, as on main. + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + session.execute("cell.set", json!({"cell": "C2", "input": "=1+1"})).unwrap(); + session.execute("home.formatCells", json!({"range": "C2", "style": {"protection": {"hidden": true, "locked": false}}})).unwrap(); + session.execute("selection.set", json!({"cell": "C2"})).unwrap(); + let mut app = SheetApp::new(session, crate::Services::default()); + let ctx = egui::Context::default(); + call(&mut app, &ctx, "ui.edit.begin", json!({"text": "=2+2"})); + execute(&mut app, &ctx, "review.protectSheet", json!({})); + assert!(app.editor.as_ref().is_some_and(|e| e.text == "=2+2"), "a formula draft is unchanged"); +} diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index 8fe3bc7d2..d3cab75ea 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -13,7 +13,19 @@ pub struct EditState { /// The window it was typed in: switching to another window, even on the same workbook, /// commits it there first (`None` until the app sets it). pub window: Option, + /// The sheet's index when the draft was last resolved; [`EditState::sheet_in`] finds it again + /// by `sheet_uid` after sheets are inserted, deleted or moved. pub sheet: usize, + /// The sheet's identity while the app runs (`model::SheetUid`), recorded when the draft is + /// created ([`EditState::on_sheet`]); 0 (never given out) when none was. + pub sheet_uid: u64, + /// The draft began on a Python cell, with its code as the text. Kept so the code stays out of + /// view once the cell can't be looked at, its sheet gone. + pub py_cell: bool, + /// The order of the workbook's sheets ([`sheet_order`]) when the draft was last resolved: a + /// change after a command points the draft at its sheet again and lets go of + /// `point_anchor`, which holds a sheet index. + pub sheet_order: u64, /// Syntax of this edit, captured when it starts (prevents a language switch reinterpreting it). pub locale: gridcraft_engine::formula::FormulaLocale, pub cell: CellRef, @@ -47,10 +59,15 @@ pub struct EditState { /// The cell's text when an F2-style edit began. Committing it unchanged leaves the cell alone /// (no undo step, the workbook stays clean). `None` for an edit started by typing. pub original: Option, - /// The edit began on a Python cell's existing content (F2, a double click or the formula - /// bar), so the text is the cell's code: a change goes in as new code through `python.set`. - /// `false` for an edit started by typing, which replaces the cell as it does a formula. - pub code_edit: bool, + /// Python mode: the text is a Python cell's code, edited in the formula bar's code editor and + /// committed with Ctrl+Enter (Cmd+Enter on macOS) through `python.set`. + pub python: bool, +} + +/// A digest of the order of `wb`'s sheets by identity: it changes when a sheet is inserted, +/// deleted or moved, not when one is renamed or edited. +pub fn sheet_order(wb: &gridcraft_engine::model::Workbook) -> u64 { + wb.sheets.iter().fold(0xcbf2_9ce4_8422_2325_u64, |h, sh| (h ^ sh.uid.get()).wrapping_mul(0x0000_0100_0000_01b3)) } impl EditState { @@ -61,6 +78,9 @@ impl EditState { doc, window: None, sheet, + sheet_uid: 0, + py_cell: false, + sheet_order: 0, locale: Default::default(), cell, text, @@ -79,14 +99,79 @@ impl EditState { caret_to_end: true, completion: None, original, - code_edit: false, + python: false, } } + /// An edit of a cell's Python code, in the formula bar. + pub fn python(doc: u64, sheet: usize, cell: CellRef, code: String) -> EditState { + let mut ed = EditState::new(doc, sheet, cell, code, false, true); + ed.python = true; + ed + } + + /// Records the identity of the draft's sheet, from the workbook it is typed in. + pub fn on_sheet(mut self, wb: &gridcraft_engine::model::Workbook) -> EditState { + self.sheet_uid = wb.sheet(self.sheet).map_or(0, |sh| sh.uid.get()); + self.sheet_order = sheet_order(wb); + self + } + + /// The draft's sheet's index now, found by its identity, since commands insert, delete and + /// move sheets under an open draft; `None` once the sheet is gone (or none was recorded). + pub fn sheet_in(&self, wb: &gridcraft_engine::model::Workbook) -> Option { + if self.sheet_uid == 0 { + return None; + } + if wb.sheet(self.sheet).is_some_and(|sh| sh.uid.get() == self.sheet_uid) { + return Some(self.sheet); + } + wb.sheets.iter().position(|sh| sh.uid.get() == self.sheet_uid) + } + + /// Whether the text is the `=PY` that Tab turns into Python mode, as in Excel (`=PY(` too, + /// in any letter case). + pub fn is_py_trigger(&self) -> bool { + let t = self.text.trim(); + !self.python && (t.eq_ignore_ascii_case("=PY") || t.eq_ignore_ascii_case("=PY(")) + } + + /// Switches the edit to Python mode, in the formula bar. The `=PY` trigger becomes empty code; + /// other text is kept as the code, so the shortcut pressed mid-edit loses nothing. + pub fn enter_python(&mut self) { + if self.is_py_trigger() { + self.text.clear(); + } + self.python = true; + self.from_formula_bar = true; + self.enter_mode = false; + self.point = None; + self.point_cell = None; + self.point_anchor = None; + self.autocomplete.clear(); + self.completion = None; + self.caret = self.text.chars().count(); + self.anchor = self.caret; + self.caret_to_end = false; + self.request_focus = true; + self.sync_caret = true; + } + + /// Leaves Python mode: the edit goes on as an ordinary entry of the same text. + pub fn leave_python(&mut self) { + self.python = false; + self.caret = self.text.chars().count(); + self.anchor = self.caret; + self.request_focus = true; + self.sync_caret = true; + } + + /// Whether the text is a formula (never in Python mode: there it is code). pub fn is_formula(&self) -> bool { - self.text.starts_with('=') - || (self.text.starts_with('+') && self.text.len() > 1) - || (self.text.starts_with('-') && self.text.len() > 1 && !self.text[1..].starts_with(|c: char| c.is_ascii_digit())) + !self.python + && (self.text.starts_with('=') + || (self.text.starts_with('+') && self.text.len() > 1) + || (self.text.starts_with('-') && self.text.len() > 1 && !self.text[1..].starts_with(|c: char| c.is_ascii_digit()))) } fn byte_caret(&self) -> usize { @@ -162,7 +247,7 @@ impl EditState { /// Updates function autocomplete for the word before the caret. pub fn update_autocomplete(&mut self, names: &[String]) { self.autocomplete.clear(); - if !self.is_formula() { + if self.python || !self.is_formula() { return; } let before = self.text.get(..self.byte_caret()).unwrap_or(""); @@ -236,7 +321,7 @@ impl EditState { /// The function call the caret is inside and the argument index (for the hint tooltip). pub fn current_function(&self) -> Option<(String, usize)> { - if !self.is_formula() { + if self.python || !self.is_formula() { return None; } let before = self.text.get(..self.byte_caret())?; diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 1f0bf11b3..3f2d08a60 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -1,9 +1,11 @@ -//! The formula bar (Name Box, ✕ ✓ fx, formula field) and the shared cell editor widget. +//! The formula bar (Name Box, ✕ ✓ fx, the PY badge, formula field) and the shared cell editor +//! widget. In Python mode the field is the code editor. use egui::{FontId, Key, Rect, Sense, Stroke, StrokeKind, TextEdit, pos2, vec2}; use serde_json::json; use crate::SheetApp; +use crate::code_editor::{self, FieldOptions, Lang}; use crate::editor::formula_refs; use crate::icons::{self, Icon}; use crate::theme::{self, Tokens}; @@ -100,6 +102,9 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { app.open_dialog("insertFunction", json!({})); } layout.buttons = cancel.rect.union(fx.rect); + if let Some(badge) = python_badge(app, ui, &t) { + layout.buttons = layout.buttons.union(badge); + } ui.add_space(4.0); // The formula field fills the rest, as tall as the bar. let avail = ui.available_width() - 24.0; @@ -114,10 +119,16 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let info = if app.editor.is_some() { editor_widget(app, &mut child, id, font.clone(), true, inner.width(), inner.height()) } else { - // Show the active cell's content; clicking starts editing in the bar. + // Show the active cell's content (a Python cell's code in the code editor's + // colours); clicking starts editing in the bar. let text = active_input(app); let color = if read_only { t.text_disabled } else { t.text }; - let galley = child.painter().layout(text, font.clone(), color, inner.width()); + let galley = if active_python(app).is_some() && !read_only { + let job = code_editor::layout_job(&text, Lang::Python, &code_editor::font(), color, &t.syntax, inner.width()); + child.fonts_mut(|f| f.layout_job(job)) + } else { + child.painter().layout(text, font.clone(), color, inner.width()) + }; let lines = galley.rows.len(); // Each cell's view starts at its top: the offset is kept per cell, not per bar. let shown = app.session.active().map(|d| (d.uid, d.wb.active_sheet(), d.selection.active.row, d.selection.active.col)); @@ -156,6 +167,16 @@ pub(crate) fn active_spill_anchor(app: &SheetApp) -> Option Option<(usize, gridcraft_engine::core::CellRef, std::sync::Arc)> { + let d = app.session.active()?; + let sh = d.wb.active()?; + let a = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active); + let at = sh.spill_anchor(a).unwrap_or(a); + sh.cell(at).and_then(|c| c.py.clone()).map(|py| (d.wb.active_sheet(), at, py)) +} + fn active_input(app: &SheetApp) -> String { let Some(d) = app.session.active() else { return String::new() }; let Some(sh) = d.wb.active() else { return String::new() }; @@ -163,6 +184,14 @@ fn active_input(app: &SheetApp) -> String { if let Some(picture) = sh.cell_pictures.get(&a) { return if picture.alt.is_empty() { "Picture in cell".into() } else { format!("Picture: {}", picture.alt) }; } + // Python code is shown as it is: it isn't a formula to translate or a value to format. Like + // a formula, it stays out of view in a Hidden cell on a protected sheet. + if let Some((_, at, py)) = active_python(app) { + if gridcraft_engine::display::formula_hidden(&d.wb, sh, at) { + return String::new(); + } + return py.code.clone(); + } if let Some(anchor) = sh.spill_anchor(a) { if gridcraft_engine::display::formula_hidden(&d.wb, sh, anchor) { return String::new(); @@ -188,6 +217,66 @@ fn active_input(app: &SheetApp) -> String { String::new() } +/// Ctrl+Alt+Shift+P, and Cmd+Option+Shift+P on macOS: Python mode on or off. +pub fn is_python_shortcut(ev: &egui::Event) -> bool { + matches!(ev, egui::Event::Key { key: Key::P, pressed: true, modifiers: m, .. } if m.shift && m.alt && (m.ctrl || m.command || m.mac_cmd)) +} + +/// Takes Ctrl+Alt+Shift+P out of this pass's input; whether it was there. +fn take_python_shortcut(i: &mut egui::InputState) -> bool { + let found = i.events.iter().any(is_python_shortcut); + if found { + i.events.retain(|e| !is_python_shortcut(e)); + } + found +} + +/// What a key in Python mode does, besides editing the code. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PythonKey { + /// Ctrl+Enter (Cmd+Enter on macOS): put the code in the cell. + Commit, + /// Esc: cancel the edit. + Cancel, + /// Ctrl+Alt+Shift+P: leave Python mode. + Leave, +} + +fn python_key(ev: &egui::Event) -> Option { + match ev { + egui::Event::Key { key: Key::Enter, pressed: true, modifiers: m, .. } if (m.ctrl || m.command || m.mac_cmd) && !m.alt => { + Some(PythonKey::Commit) + } + egui::Event::Key { key: Key::Escape, pressed: true, .. } => Some(PythonKey::Cancel), + _ if is_python_shortcut(ev) => Some(PythonKey::Leave), + _ => None, + } +} + +/// The PY badge beside fx: shown in Python mode and on a selected Python cell. Clicking it +/// switches Python mode, as Ctrl+Alt+Shift+P does. Returns its rect when shown. +fn python_badge(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) -> Option { + let show = match &app.editor { + Some(ed) => ed.python, + None => active_python(app).is_some(), + }; + if !show { + return None; + } + let text = ui.painter().layout_no_wrap("PY".into(), theme::ui_bold(11.0), t.on_badge); + let (rect, resp) = ui.allocate_exact_size(vec2(text.size().x + 12.0, 24.0), Sense::click()); + let pill = Rect::from_center_size(rect.center(), vec2(rect.width(), 18.0)); + ui.painter().rect_filled(pill, 4.0, t.badge_py); + ui.painter().galley(pill.center() - text.size() / 2.0, text, t.on_badge); + let label = app.ui.language.tr("Python Mode").to_string(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); + let resp = resp.on_hover_text(format!("{label} (Ctrl+Alt+Shift+P)")); + if resp.clicked() { + app.toggle_python_mode(); + } + Some(rect) +} + /// Draws the Name Box and its name dropdown; Enter jumps to a reference or known name (or defines a new name) and is consumed so the grid does not move down. fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) -> Rect { let id = egui::Id::unique("gridcraft.name_box"); @@ -292,12 +381,19 @@ fn show_edit<'t>( /// The text editor used in the cell and in the formula bar (they share `app.editor`). It shows /// `max_height` of text and scrolls beyond that, keeping the caret in view. pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32, max_height: f32) -> EditorInfo { + if app.editor.as_ref().is_some_and(|e| e.python) { + // Python mode edits in the formula bar only. + if id != egui::Id::unique("gridcraft.formula_bar") { + return EditorInfo::default(); + } + return python_editor(app, ui, id, width, max_height); + } let Some(mut ed) = app.editor.take() else { return EditorInfo::default() }; let mine = ed.from_formula_bar == (id == egui::Id::unique("gridcraft.formula_bar")); // Keys the editor handles itself (before the TextEdit sees them), only where it has focus. let focused = ui.ctx().memory(|m| m.focused()); let has_focus = focused == Some(id) || (mine && (ed.request_focus || focused.is_none())); - let EditorKeyOutcome { action, arrow, cancel } = editor_shortcuts(app, ui, id, &mut ed, mine, has_focus); + let EditorKeyOutcome { action, arrow, cancel, to_python } = editor_shortcuts(app, ui, id, &mut ed, mine, has_focus); // The text widget. let refs = formula_refs(&ed.text); let t = Tokens::get(ui.ctx()); @@ -393,7 +489,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: // Python code is never completed from the column's text values. if app.ui.autocomplete && !ed.is_formula() - && !ed.code_edit + && !ed.python && !entry_shrank(chars_before, &ed.text) && ed.caret == ed.text.chars().count() && let Some(sh) = app.session.active().and_then(|d| d.wb.sheet(ed.sheet)) @@ -422,6 +518,12 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: show_editor_suggestions(ui, EditorSuggestion { app, id, resp: &resp, ed: &mut ed, galley: &output.galley, tokens: &t, mine }); let lines = output.galley.rows.len().max(1); let caret = Some(output.galley.pos_from_cursor(egui::text::CCursor::new(ed.caret)).translate(output.galley_pos.to_vec2())); + if to_python { + ed.enter_python(); + app.editor = Some(ed); + app.session.mode = gridcraft_engine::Mode::Edit; + return EditorInfo { lines, caret }; + } app.editor = Some(ed); if cancel { app.cancel_edit(); @@ -441,6 +543,8 @@ struct EditorKeyOutcome { action: Option, arrow: bool, cancel: bool, + /// Ctrl+Alt+Shift+P, or `=PY` then Tab: the edit turns into Python code. + to_python: bool, } fn editor_shortcuts( @@ -451,14 +555,17 @@ fn editor_shortcuts( mine: bool, has_focus: bool, ) -> EditorKeyOutcome { - let mut outcome = EditorKeyOutcome { action: None, arrow: false, cancel: false }; + let mut outcome = EditorKeyOutcome { action: None, arrow: false, cancel: false, to_python: false }; // A message or dialog owns Enter and Escape until it closes. if !has_focus || !mine || app.message.is_some() || app.dialog.is_some() { return outcome; } - let (action, cancel) = editor_confirm_keys(ui, ed); + // Ctrl+Alt+Shift+P turns the edit into Python code. + outcome.to_python = ui.input_mut(take_python_shortcut); + let (action, cancel, py_trigger) = editor_confirm_keys(ui, ed); outcome.action = action; outcome.cancel = cancel; + outcome.to_python |= py_trigger; let (action, arrow) = editor_arrow_keys(app, ui, ed, outcome.action); outcome.action = action; outcome.arrow = arrow; @@ -466,7 +573,9 @@ fn editor_shortcuts( outcome } -fn editor_confirm_keys(ui: &mut egui::Ui, ed: &mut crate::editor::EditState) -> (Option, bool) { +/// Enter, Tab, Esc and their variants: the move to make, whether the edit is cancelled, and +/// whether `=PY` then Tab starts Python mode. +fn editor_confirm_keys(ui: &mut egui::Ui, ed: &mut crate::editor::EditState) -> (Option, bool, bool) { let (enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, shift) = ui.input_mut(|i| { let m = i.modifiers; let alt_enter = i.consume_key(egui::Modifiers::ALT, Key::Enter) || i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::ALT, Key::Enter); @@ -485,7 +594,11 @@ fn editor_confirm_keys(ui: &mut egui::Ui, ed: &mut crate::editor::EditState) -> ed.caret_to_end = false; ed.sync_caret = true; } - let mut action = if !ed.autocomplete.is_empty() && tab { + // `=PY` then Tab starts Python mode, as in Excel. + let py_trigger = tab && !shift && ed.is_py_trigger(); + let mut action = if py_trigger { + None + } else if !ed.autocomplete.is_empty() && tab { ed.accept_autocomplete(); None } else if enter { @@ -512,7 +625,7 @@ fn editor_confirm_keys(ui: &mut egui::Ui, ed: &mut crate::editor::EditState) -> } else { true }; - (action, cancel) + (action, cancel, py_trigger) } fn editor_arrow_keys( @@ -606,6 +719,57 @@ fn editor_other_shortcuts(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, e } } +/// The formula bar in Python mode: the code editor (monospace, Python colours). Its keys are +/// applied in input order with the typing: Enter starts a new line with the indentation, Tab and +/// Shift+Tab indent, Ctrl+Enter (Cmd+Enter on macOS) commits through `python.set`, Esc cancels and +/// Ctrl+Alt+Shift+P leaves Python mode. +fn python_editor(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, width: f32, max_height: f32) -> EditorInfo { + let Some(mut ed) = app.editor.take() else { return EditorInfo::default() }; + // A message box (the code was refused) or a dialog over the edit has the keyboard; the + // editor takes it back when it closes. + let blocked = app.message.is_some() || app.dialog.is_some(); + let mut caret = None; + if blocked { + ed.request_focus = true; + } else { + // Focus left on nothing (a click on the sheet's chrome, a closed message box whose modal + // layer held it one more pass) comes back to the code. + if std::mem::take(&mut ed.request_focus) || ui.memory(|m| m.focused()).is_none() { + ui.memory_mut(|m| m.request_focus(id)); + } + if std::mem::take(&mut ed.sync_caret) { + caret = Some(ed.caret); + } + } + let name = app.ui.language.tr("Formula Bar").to_string(); + let stop = |ev: &egui::Event| python_key(ev).is_some(); + let options = FieldOptions { + width, + max_height, + interactive: !blocked, + stop: &stop, + keep_escape: true, + caret, + name: &name, + ..FieldOptions::new(Lang::Python) + }; + let out = code_editor::code_field(ui, id, &mut ed.text, &options); + if !blocked && caret.is_none() { + (ed.caret, ed.anchor) = out.selection; + } + let info = EditorInfo { lines: out.lines, caret: out.caret }; + app.editor = Some(ed); + match out.stop.as_ref().and_then(python_key) { + Some(PythonKey::Commit) => { + app.commit_python_edit(); + } + Some(PythonKey::Cancel) => app.cancel_edit(), + Some(PythonKey::Leave) => app.toggle_python_mode(), + None => {} + } + info +} + fn cycle_anchor(ed: &mut crate::editor::EditState) { let caret_b = ed.text.char_indices().nth(ed.caret).map(|(i, _)| i).unwrap_or(ed.text.len()); for (a, b, ..) in formula_refs(&ed.text) { diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index ea5daf33c..5217724c0 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -39,6 +39,8 @@ pub enum Shortcut { DropDown, /// Ctrl+Alt+= / Ctrl+Alt+-: zoom in / out. Zoom(i32), + /// Ctrl+Alt+Shift+P: edit the active cell as Python code (Python mode). + PythonMode, } fn run(id: &'static str) -> Shortcut { @@ -219,6 +221,8 @@ fn lookup_control_alt_shortcuts(ctx: LookupContext) -> Option { Equals | Plus if ctrl_alt => Shortcut::Zoom(10), Minus if ctrl_alt && !mac => Shortcut::Zoom(-10), F9 if ctrl_alt => run("formulas.calculateNow"), + // Python in cells (Cmd+Option on macOS too). + P if ctrl_alt && s => Shortcut::PythonMode, R if ctrl_alt && mac => run("view.collapseRibbon"), Num0 if ctrl_alt && mac => borders("outside"), Minus if ctrl_alt && mac => borders("none"), @@ -342,6 +346,7 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic app.grid.reveal_from = from; } Shortcut::Edit => app.begin_edit(None, false), + Shortcut::PythonMode => app.toggle_python_mode(), Shortcut::ContextMenu => app.grid.context_menu = Some(geo.cell_rect(sh, active).center()), Shortcut::DropDown => { if sh.validations.iter().any(|d| d.in_cell_dropdown && d.ranges.iter().any(|r| r.contains(active))) { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index f37eb0c35..d79aa6a29 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -114,6 +114,13 @@ pub struct Tokens { pub sheet_macro_footprint: Color32, pub sheet_macro_conflict: Color32, pub sheet_macro_target: Color32, + /// The PY badge in the formula bar (Python mode) and its text. + pub badge_py: Color32, + pub on_badge: Color32, + /// The PY tag on a Python cell and its text. They follow the sheet, so they are the same in + /// both themes. + pub sheet_py_tag: Color32, + pub on_sheet_py_tag: Color32, } /// The eight category colours, in the order of [`Tokens::cat`]. @@ -165,6 +172,8 @@ const SHEET_CONTROL_BG: Color32 = rgb(0xF3F3F3); const SHEET_CONTROL_BORDER: Color32 = rgb(0x8A8A8A); const SHEET_CONTROL_TEXT: Color32 = rgb(0x1F1F1F); const SHEET_TRACK: Color32 = rgb(0xD6D6D6); +/// The PY tag on Python cells: a deep green, white text on it. +const SHEET_PY_TAG: Color32 = rgb(0x1E6E4E); impl Tokens { pub fn light() -> Tokens { @@ -244,6 +253,10 @@ impl Tokens { sheet_macro_footprint: SHEET_MACRO_FOOTPRINT, sheet_macro_conflict: SHEET_MACRO_CONFLICT, sheet_macro_target: SHEET_MACRO_TARGET, + badge_py: pick(dark, SHEET_PY_TAG, rgb(0x5CBF94)), + on_badge: pick(dark, Color32::WHITE, rgb(0x0B1F17)), + sheet_py_tag: SHEET_PY_TAG, + on_sheet_py_tag: Color32::WHITE, } } @@ -401,6 +414,11 @@ mod contrast_tests { add("text on current step", t.text, t.accent_soft, TEXT); add("current step arrow", t.accent, t.accent_soft, GLYPH); add("error marker on window", t.danger, t.window, GLYPH); + add("PY badge text", t.on_badge, t.badge_py, TEXT); + add("PY badge on window", t.badge_py, t.window, GLYPH); + add("PY badge on input", t.badge_py, t.input_bg, GLYPH); + add("PY tag text", t.on_sheet_py_tag, t.sheet_py_tag, TEXT); + add("PY tag on sheet", t.sheet_py_tag, t.grid_bg, GLYPH); v } diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index a70dc449b..a6a029df3 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -83,6 +83,8 @@ mod popup_dismissal; mod protect_password; mod protected_cell; mod python_cell_edit; +mod python_hidden; +mod python_mode; mod ribbon_layout; mod right_to_left; mod scroll_position; diff --git a/crates/ui-egui/tests/it/python_cell_edit.rs b/crates/ui-egui/tests/it/python_cell_edit.rs index 3153299c5..66a922b04 100644 --- a/crates/ui-egui/tests/it/python_cell_edit.rs +++ b/crates/ui-egui/tests/it/python_cell_edit.rs @@ -1,5 +1,5 @@ -//! Editing a Python cell's content (F2, the formula bar) edits its code; typing over it replaces -//! it, as typing over a formula does. +//! Editing a Python cell's content (F2, the formula bar) edits its code, in Python mode; typing +//! over it replaces it, as typing over a formula does. use egui::{Event, Key, Modifiers}; use gridcraft_engine::Session; @@ -22,13 +22,22 @@ fn text(h: &mut Harness, text: &str) { h.run_steps(2); } -fn key(h: &mut Harness, key: Key) { +fn key_with(h: &mut Harness, key: Key, modifiers: Modifiers) { for pressed in [true, false] { - h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); } h.run_steps(2); } +fn key(h: &mut Harness, key: Key) { + key_with(h, key, Modifiers::NONE); +} + +/// Ctrl+Enter (Cmd+Enter on macOS): commits Python mode, where Enter starts a new line. +fn commit(h: &mut Harness) { + key_with(h, Key::Enter, Modifiers::COMMAND); +} + /// A1's code and return type (true: a Python object), or `None` when it isn't a Python cell. fn python(h: &Harness) -> Option<(String, bool)> { let sh = h.state().session.active().unwrap().wb.active().unwrap(); @@ -46,10 +55,10 @@ fn f2_edits_a_python_cells_code_and_typing_replaces_it() { let undo_depth = |h: &Harness| h.state().session.active().unwrap().undo.len(); let before = undo_depth(&h); - // F2 and Enter without a change writes nothing. + // F2 and a commit without a change writes nothing. key(&mut h, Key::F2); - assert_eq!(h.state().editor.as_ref().map(|e| e.text.as_str()), Some("1 + 1")); - key(&mut h, Key::Enter); + assert_eq!(h.state().editor.as_ref().map(|e| (e.text.as_str(), e.python)), Some(("1 + 1", true))); + commit(&mut h); assert!(h.state().editor.is_none()); assert_eq!(undo_depth(&h), before, "an unchanged edit is no undo step"); assert_eq!(python(&h), Some(("1 + 1".into(), true))); @@ -58,7 +67,7 @@ fn f2_edits_a_python_cells_code_and_typing_replaces_it() { select_a1(&mut h); key(&mut h, Key::F2); text(&mut h, " + 2"); - key(&mut h, Key::Enter); + commit(&mut h); assert!(h.state().editor.is_none(), "{:?}", h.state().message); assert_eq!(python(&h), Some(("1 + 1 + 2".into(), true)), "the cell stays a Python cell with the new code"); @@ -123,6 +132,6 @@ fn a_code_edit_is_never_completed_from_the_column() { key(&mut h, Key::F2); text(&mut h, " "); assert_eq!(h.state().editor.as_ref().and_then(|e| e.completion.clone()), None, "no completion while code is edited"); - key(&mut h, Key::Enter); + commit(&mut h); assert_eq!(python(&h), Some(("1 + 1 ".into(), true)), "the typed code is kept"); } diff --git a/crates/ui-egui/tests/it/python_hidden.rs b/crates/ui-egui/tests/it/python_hidden.rs new file mode 100644 index 000000000..705728d6c --- /dev/null +++ b/crates/ui-egui/tests/it/python_hidden.rs @@ -0,0 +1,137 @@ +//! A Python cell formatted Hidden on a protected sheet keeps its code out of view, as a hidden +//! formula does: the formula bar shows nothing, and F2, a double-click, the formula bar, +//! Ctrl+Alt+Shift+P and the PY badge start no edit that would show the code, or +//! that would write blank code over it. + +use egui::{Event, Key, Modifiers, vec2}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use serde_json::json; + +use crate::common::Harness; + +const CODE: &str = "secret_total = 41\nsecret_total"; + +/// A Python cell at B2, selected, with its protection `hidden` flag, on a sheet protected or not. +fn harness(hidden: bool, protected: bool) -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("python.set", json!({"cell": "B2", "code": CODE})).unwrap(); + s.execute("home.formatCells", json!({"range": "B2", "style": {"protection": {"locked": false, "hidden": hidden}}})).unwrap(); + if protected { + s.execute("review.protectSheet", json!({})).unwrap(); + } + s.execute("selection.set", json!({"cell": "B2"})).unwrap(); + crate::common::harness_with(crate::common::builder().with_size(vec2(1200.0, 800.0)), crate::common::app(s), 4) +} + +fn send(h: &mut Harness, events: impl IntoIterator) { + h.input_mut().events.extend(events); + h.run_steps(3); +} + +fn press(h: &mut Harness, key: Key, modifiers: Modifiers) { + send( + h, + [ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ], + ); +} + +const CTRL_ALT_SHIFT: Modifiers = Modifiers { alt: true, ctrl: true, shift: true, mac_cmd: false, command: true }; + +/// Two clicks on B2 in a row: a double-click on the grid. +fn double_click_b2(h: &mut Harness) { + let at = { + let app = h.state(); + let sh = app.session.active().unwrap().wb.active().unwrap(); + app.grid.geo.as_ref().unwrap().screen_cell_rect(sh, CellRef::parse("B2").unwrap()).center() + }; + for _ in 0..2 { + for event in [ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ] { + h.input_mut().events.push(event); + h.run_steps(1); + } + } + h.run_steps(2); +} + +/// The open edit's text, and whether it is in Python mode. +fn editor(h: &Harness) -> Option<(String, bool)> { + h.state().editor.as_ref().map(|e| (e.text.clone(), e.python)) +} + +fn bar_shows_code(h: &Harness) -> bool { + h.query_by_label_contains("secret_total").is_some() +} + +fn stored_code(h: &Harness) -> Option { + let d = h.state().session.active()?; + d.wb.active()?.cell(CellRef::parse("B2")?)?.py.as_ref().map(|p| p.code.clone()) +} + +fn cancel(h: &mut Harness) { + h.state_mut().cancel_edit(); + h.run_steps(2); +} + +#[test] +fn a_hidden_python_cell_on_a_protected_sheet_shows_no_code_in_the_formula_bar() { + let h = harness(true, true); + assert!(!bar_shows_code(&h), "the formula bar shows nothing for hidden code"); + assert!(h.query_by_label("Python Mode").is_some(), "the PY badge still marks a Python cell"); +} + +#[test] +fn f2_a_double_click_and_the_formula_bar_start_no_edit_on_hidden_code() { + let mut h = harness(true, true); + press(&mut h, Key::F2, Modifiers::NONE); + assert_eq!(editor(&h), None, "F2"); + double_click_b2(&mut h); + assert_eq!(editor(&h), None, "a double-click"); + h.state_mut().begin_edit(None, true); + assert_eq!(editor(&h), None, "a click in the formula bar"); + assert!(!bar_shows_code(&h)); + assert_eq!(stored_code(&h).as_deref(), Some(CODE), "the code is untouched"); +} + +#[test] +fn python_mode_starts_no_edit_on_hidden_code() { + let mut h = harness(true, true); + press(&mut h, Key::P, CTRL_ALT_SHIFT); + assert_eq!(editor(&h), None, "Ctrl+Alt+Shift+P"); + 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"); + assert!(!bar_shows_code(&h)); + // Ctrl+Enter with no edit open writes nothing over the code. + press(&mut h, Key::Enter, Modifiers::COMMAND); + assert_eq!(stored_code(&h).as_deref(), Some(CODE)); +} + +/// The same cell, unprotected or not hidden, shows and edits its code. +#[test] +fn the_code_shows_and_edits_when_the_sheet_is_unprotected_or_the_cell_not_hidden() { + for (hidden, protected) in [(true, false), (false, true)] { + let what = format!("hidden {hidden}, protected {protected}"); + let mut h = harness(hidden, protected); + assert!(bar_shows_code(&h), "{what}: the formula bar shows the code"); + press(&mut h, Key::F2, Modifiers::NONE); + assert_eq!(editor(&h), Some((CODE.to_string(), true)), "{what}: F2"); + cancel(&mut h); + double_click_b2(&mut h); + assert_eq!(editor(&h), Some((CODE.to_string(), true)), "{what}: a double-click"); + cancel(&mut h); + press(&mut h, Key::P, CTRL_ALT_SHIFT); + assert_eq!(editor(&h), Some((CODE.to_string(), true)), "{what}: Ctrl+Alt+Shift+P"); + cancel(&mut h); + } +} diff --git a/crates/ui-egui/tests/it/python_mode.rs b/crates/ui-egui/tests/it/python_mode.rs new file mode 100644 index 000000000..6e88e39d4 --- /dev/null +++ b/crates/ui-egui/tests/it/python_mode.rs @@ -0,0 +1,409 @@ +//! Python mode in the formula bar: entering and leaving it, its keys (applied in input order, also +//! several in one frame), committing through `python.set`, and the PY badge. + +use egui::{Event, Key, Modifiers, vec2}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellError, CellRef, Value}; +use serde_json::json; + +use crate::common::Harness; + +fn blank() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s +} + +fn harness(session: Session) -> Harness { + crate::common::harness_with(crate::common::builder().with_size(vec2(1200.0, 800.0)), crate::common::app(session), 4) +} + +fn mac_harness(session: Session) -> Harness { + crate::common::os_harness(session, egui::os::OperatingSystem::Mac) +} + +fn press(key: Key, modifiers: Modifiers) -> [Event; 2] { + [ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ] +} + +/// Sends `events` in one frame, then lets the app settle. +fn send(h: &mut Harness, events: impl IntoIterator) { + h.input_mut().events.extend(events); + h.run_steps(3); +} + +fn type_text(h: &mut Harness, text: &str) { + send(h, [Event::Text(text.into())]); +} + +const CTRL_ALT_SHIFT: Modifiers = Modifiers { alt: true, ctrl: true, shift: true, mac_cmd: false, command: true }; +const CMD_OPTION_SHIFT: Modifiers = Modifiers { alt: true, ctrl: false, shift: true, mac_cmd: true, command: true }; + +fn shortcut(h: &mut Harness) { + send(h, press(Key::P, CTRL_ALT_SHIFT)); +} + +fn ctrl_enter(h: &mut Harness) { + send(h, press(Key::Enter, Modifiers::COMMAND)); +} + +/// Whether an edit is open, and in Python mode. +fn mode(h: &Harness) -> Option { + h.state().editor.as_ref().map(|e| e.python) +} + +fn text(h: &Harness) -> String { + h.state().editor.as_ref().map(|e| e.text.clone()).unwrap_or_default() +} + +fn code(h: &Harness, cell: &str) -> Option { + let d = h.state().session.active()?; + d.wb.active()?.cell(CellRef::parse(cell)?)?.py.as_ref().map(|p| p.code.clone()) +} + +fn value(h: &Harness, cell: &str) -> Value { + let d = h.state().session.active().unwrap(); + d.wb.active().unwrap().value(CellRef::parse(cell).unwrap()) +} + +#[test] +fn the_shortcut_enters_and_leaves_python_mode_and_esc_cancels() { + let mut h = harness(blank()); + shortcut(&mut h); + assert_eq!(mode(&h), Some(true), "Ctrl+Alt+Shift+P starts Python mode on the active cell"); + let ed = h.state().editor.as_ref().unwrap(); + assert!(ed.from_formula_bar, "Python mode edits in the formula bar"); + assert_eq!(ed.cell, CellRef::new(0, 0)); + h.check_accessibility(); + type_text(&mut h, "x = 1"); + assert_eq!(text(&h), "x = 1"); + shortcut(&mut h); + assert_eq!(mode(&h), Some(false), "the shortcut again leaves Python mode"); + assert_eq!(text(&h), "x = 1", "and the edit keeps its text"); + shortcut(&mut h); + assert_eq!(mode(&h), Some(true)); + send(&mut h, press(Key::Escape, Modifiers::NONE)); + assert_eq!(mode(&h), None, "Esc cancels the edit"); + assert_eq!(code(&h, "A1"), None, "and changes nothing"); + assert_eq!(value(&h, "A1"), Value::Empty); +} + +#[test] +fn the_mac_shortcut_is_cmd_option_shift_p_and_cmd_enter_commits() { + let mut h = mac_harness(blank()); + send(&mut h, press(Key::P, CMD_OPTION_SHIFT)); + assert_eq!(mode(&h), Some(true)); + type_text(&mut h, "1 + 1"); + send(&mut h, press(Key::Enter, Modifiers::MAC_CMD | Modifiers::COMMAND)); + assert_eq!(mode(&h), None); + assert_eq!(code(&h, "A1").as_deref(), Some("1 + 1")); +} + +#[test] +fn typing_py_then_tab_enters_python_mode_as_in_excel() { + let mut h = harness(blank()); + type_text(&mut h, "=PY"); + assert_eq!(mode(&h), Some(false), "typing starts an ordinary entry in the cell"); + send(&mut h, press(Key::Tab, Modifiers::NONE)); + assert_eq!(mode(&h), Some(true), "=PY then Tab is Python mode"); + assert_eq!(text(&h), "", "the trigger is not code"); + assert!(h.state().editor.as_ref().is_some_and(|e| e.from_formula_bar)); + let active = h.state().session.active().map(|d| d.selection.active); + assert_eq!(active, Some(CellRef::new(0, 0)), "Tab did not move on"); + type_text(&mut h, "len('abc')"); + ctrl_enter(&mut h); + assert_eq!(code(&h, "A1").as_deref(), Some("len('abc')")); + assert_eq!(value(&h, "A1"), Value::Error(CellError::Blocked), "this build stores the code and doesn't run it"); + // Any other entry still commits with Tab. + h.state_mut().session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + h.run_steps(2); + type_text(&mut h, "=PI"); + send(&mut h, press(Key::Tab, Modifiers::NONE)); + assert_eq!(mode(&h), None); +} + +#[test] +fn enter_and_tab_edit_the_code_and_ctrl_enter_commits_through_python_set() { + let mut h = harness(blank()); + h.state_mut().session.execute("selection.set", json!({"cell": "C3"})).unwrap(); + shortcut(&mut h); + type_text(&mut h, "for i in range(3):"); + send(&mut h, press(Key::Enter, Modifiers::NONE)); + type_text(&mut h, "print(i)"); + send(&mut h, press(Key::Enter, Modifiers::NONE)); + send(&mut h, press(Key::Tab, Modifiers::SHIFT)); + type_text(&mut h, "x"); + assert_eq!(text(&h), "for i in range(3):\n print(i)\nx", "Enter keeps the indentation, Shift+Tab takes it back"); + send(&mut h, press(Key::Tab, Modifiers::NONE)); + assert_eq!(text(&h), "for i in range(3):\n print(i)\nx "); + assert_eq!(mode(&h), Some(true), "Enter and Tab stay in the edit"); + ctrl_enter(&mut h); + assert_eq!(mode(&h), None); + assert_eq!(code(&h, "C3").as_deref(), Some("for i in range(3):\n print(i)\nx ")); + assert_eq!(h.state().session.active().map(|d| d.selection.active), Some(CellRef::new(2, 2)), "Ctrl+Enter stays on the cell"); + // One undo takes the code away. + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(code(&h, "C3"), None); +} + +#[test] +fn several_keys_in_one_frame_are_applied_in_order() { + let mut h = harness(blank()); + shortcut(&mut h); + type_text(&mut h, "a"); + send(&mut h, press(Key::Enter, Modifiers::NONE)); + type_text(&mut h, "b"); + // Select all, then Tab, in one frame: Tab indents the selected lines rather than replacing + // them (the #456 review regression). + let mut events = Vec::new(); + events.extend(press(Key::A, Modifiers::COMMAND)); + events.extend(press(Key::Tab, Modifiers::NONE)); + send(&mut h, events); + assert_eq!(text(&h), " a\n b"); + let mut events = Vec::new(); + events.extend(press(Key::A, Modifiers::COMMAND)); + events.extend(press(Key::Tab, Modifiers::SHIFT)); + send(&mut h, events); + assert_eq!(text(&h), "a\nb", "Shift+Tab after select-all dedents every line"); + // A keystroke sequence as the platform sends it: a key before each text, then Enter. + send(&mut h, press(Key::End, Modifiers::COMMAND)); + let mut events = Vec::new(); + for (k, c) in [(Key::I, "i"), (Key::F, "f"), (Key::Space, " "), (Key::Y, "y")] { + events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + events.push(Event::Text(c.into())); + } + events.push(Event::Key { key: Key::Colon, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }); + events.push(Event::Text(":".into())); + events.extend(press(Key::Enter, Modifiers::NONE)); + events.push(Event::Text("z".into())); + send(&mut h, events); + assert_eq!(text(&h), "a\nbif y:\n z"); + // Select all, Tab and Ctrl+Enter in one frame: the commit waits for the Tab. + let mut events = Vec::new(); + events.extend(press(Key::A, Modifiers::COMMAND)); + events.extend(press(Key::Tab, Modifiers::NONE)); + events.extend(press(Key::Enter, Modifiers::COMMAND)); + send(&mut h, events); + assert_eq!(mode(&h), None); + assert_eq!(code(&h, "A1").as_deref(), Some(" a\n bif y:\n z")); +} + +#[test] +fn keys_typed_after_the_commit_key_in_the_same_frame_are_not_committed() { + let mut h = harness(blank()); + shortcut(&mut h); + let mut events = vec![Event::Text("1".into())]; + events.extend(press(Key::Enter, Modifiers::COMMAND)); + events.push(Event::Text("2".into())); + send(&mut h, events); + assert_eq!(code(&h, "A1").as_deref(), Some("1")); + assert_eq!(value(&h, "B1"), Value::Empty); + assert!(h.state().editor.is_none(), "the text after the commit key starts nothing"); +} + +#[test] +fn code_too_long_for_a_cell_shows_the_commands_message_and_keeps_the_code() { + let mut h = harness(blank()); + shortcut(&mut h); + let long = "y".repeat(8_080); + if let Some(ed) = h.state_mut().editor.as_mut() { + ed.text = long.clone(); + ed.caret = long.chars().count(); + ed.sync_caret = true; + } + h.run_steps(2); + ctrl_enter(&mut h); + let message = h.state().message.clone().map(|m| m.1).unwrap_or_default(); + assert!(message.contains("8192"), "the command's message: {message}"); + assert_eq!(mode(&h), Some(true), "the edit stays open in Python mode"); + assert_eq!(text(&h), long, "with the code as typed"); + assert_eq!(code(&h, "A1"), None); + // Keys go to the message box, not the code, until it closes. + send(&mut h, press(Key::Enter, Modifiers::NONE)); + assert!(h.state().message.is_none(), "Enter closed the message box"); + assert_eq!(text(&h), long, "and added no line to the code"); + // The editor has the keyboard back: shortening the code commits it. + send(&mut h, press(Key::Backspace, Modifiers::NONE)); + assert_eq!(text(&h).len(), 8_079); + ctrl_enter(&mut h); + assert_eq!(code(&h, "A1").map(|c| c.len()), Some(8_079)); +} + +#[test] +fn editing_a_python_cell_edits_its_code_and_typing_replaces_it() { + let mut s = blank(); + s.execute("python.set", json!({"cell": "B2", "code": "x = 1\nx"})).unwrap(); + s.execute("selection.set", json!({"cell": "B2"})).unwrap(); + let mut h = harness(s); + // The formula bar shows the code, with the PY badge. + assert!(h.query_by_label("Python Mode").is_some(), "the PY badge shows on a selected Python cell"); + send(&mut h, press(Key::F2, Modifiers::NONE)); + assert_eq!(mode(&h), Some(true), "F2 on a Python cell is Python mode"); + assert_eq!(text(&h), "x = 1\nx"); + // The badge leaves Python mode, as the shortcut does. + h.get_by_label("Python Mode").click(); + h.run_steps(3); + assert_eq!(mode(&h), Some(false)); + send(&mut h, press(Key::Escape, Modifiers::NONE)); + assert_eq!(mode(&h), None); + assert!(h.query_by_label("Python Mode").is_some()); + // Typing replaces the cell, as in Excel. + type_text(&mut h, "7"); + assert_eq!(mode(&h), Some(false)); + send(&mut h, press(Key::Enter, Modifiers::NONE)); + assert_eq!(code(&h, "B2"), None); + assert_eq!(value(&h, "B2"), Value::Number(7.0)); + // No badge on an ordinary cell. + h.state_mut().session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + h.run_steps(2); + assert!(h.query_by_label("Python Mode").is_none()); +} + +#[test] +fn ui_python_mode_switches_from_the_control_channel_without_a_dialog() { + let mut h = harness(blank()); + let r = h.state_mut().run("ui.pythonMode", json!({})).unwrap(); + assert_eq!(r["on"], true); + assert_eq!(h.state_mut().run("ui.pythonMode", json!({"on": true})).unwrap()["on"], true, "already on"); + assert_eq!(h.state_mut().run("ui.pythonMode", json!({"on": false})).unwrap()["on"], false); + assert!(h.state().dialog.is_none() && h.state().message.is_none()); + h.state_mut().cancel_edit(); + assert_eq!(h.state_mut().run("ui.pythonMode", json!({"on": false})).unwrap()["on"], false, "off with no edit does nothing"); + assert!(h.state().editor.is_none()); +} + +/// Clicks the middle of `a1` on screen. +fn click_cell(h: &mut Harness, a1: &str) { + let at = { + let app = h.state(); + let sh = app.session.active().unwrap().wb.active().unwrap(); + app.grid.geo.as_ref().unwrap().screen_cell_rect(sh, CellRef::parse(a1).unwrap()).center() + }; + for event in [ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ] { + h.input_mut().events.push(event); + h.run_steps(1); + } + h.run_steps(2); +} + +#[test] +fn the_enter_button_and_a_click_on_the_sheet_commit_the_code_through_python_set() { + let mut h = harness(blank()); + shortcut(&mut h); + type_text(&mut h, "'=SUM(1)'"); + h.get_by_label("Enter").click(); + h.run_steps(3); + assert_eq!(code(&h, "A1").as_deref(), Some("'=SUM(1)'"), "the code, not a formula or text"); + h.state_mut().session.execute("selection.set", json!({"cell": "A2"})).unwrap(); + shortcut(&mut h); + type_text(&mut h, "2"); + click_cell(&mut h, "D4"); + assert_eq!(code(&h, "A2").as_deref(), Some("2")); + assert_eq!(h.state().session.active().map(|d| d.selection.active), Some(CellRef::new(3, 3))); + assert!(h.state().editor.is_none()); +} + +#[test] +fn the_formula_bar_shows_while_python_mode_is_open_when_the_preference_hides_it() { + let mut h = harness(blank()); + h.state_mut().session.prefs.formula_bar = false; + h.run_steps(2); + shortcut(&mut h); + assert_eq!(mode(&h), Some(true)); + type_text(&mut h, "x = 1"); + assert_eq!(text(&h), "x = 1", "the code editor is drawn and takes the typing"); + ctrl_enter(&mut h); + assert!(h.state().editor.is_none()); + assert_eq!(code(&h, "A1").as_deref(), Some("x = 1")); + assert!(!h.state().session.prefs.formula_bar, "the preference stays as it was"); +} + +#[test] +fn committing_no_code_clears_a_python_cell_and_writes_nothing_elsewhere() { + let mut s = blank(); + s.execute("python.set", json!({"cell": "A1", "code": "1 + 1"})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "5"})).unwrap(); + let mut h = harness(s); + // A Python cell whose code is deleted is cleared, as an empty entry clears any other cell. + shortcut(&mut h); + h.state_mut().editor.as_mut().unwrap().text.clear(); + ctrl_enter(&mut h); + assert!(h.state().editor.is_none(), "{:?}", h.state().message); + assert_eq!(code(&h, "A1"), None); + assert_eq!(value(&h, "A1"), Value::Empty); + // Python mode on a value cell, committed with no code: the value stays. + h.state_mut().run("selection.set", json!({"range": "A2"})).unwrap(); + h.run_steps(2); + shortcut(&mut h); + assert_eq!(text(&h), ""); + ctrl_enter(&mut h); + assert!(h.state().editor.is_none()); + assert_eq!(code(&h, "A2"), None); + assert_eq!(value(&h, "A2"), Value::Number(5.0)); +} + +#[test] +fn a_python_draft_belongs_to_the_window_it_was_typed_in() { + let mut s = blank(); + s.execute("python.set", json!({"cell": "B1", "code": "2"})).unwrap(); + let mut h = harness(s); + let first = h.state().session.active_window().unwrap(); + h.state_mut().run_or_alert("view.newWindow", json!({})); + h.run_steps(2); + let second = h.state().session.active_window().unwrap(); + assert_ne!(first, second); + // Both ways into Python mode record the window, as an ordinary edit does. + shortcut(&mut h); + assert_eq!(h.state().editor.as_ref().and_then(|e| e.window), Some(second)); + send(&mut h, press(Key::Escape, Modifiers::NONE)); + h.state_mut().run("selection.set", json!({"range": "B1"})).unwrap(); + send(&mut h, press(Key::F2, Modifiers::NONE)); + assert_eq!(mode(&h), Some(true)); + assert_eq!(h.state().editor.as_ref().and_then(|e| e.window), Some(second)); + // A script brings window 1 forward; the draft is committed in its own window, and when its + // workbook refuses it (too long for a cell), that window comes back with the draft open. + h.state_mut().editor.as_mut().unwrap().text = "y".repeat(9_000); + h.state_mut().session.activate_window(first); + assert!(!h.state_mut().commit_edit(0, 0, false, false)); + assert_eq!(h.state().session.active_window(), Some(second)); + assert_eq!(mode(&h), Some(true), "the draft is still open"); +} + +/// A locked cell on a protected sheet: Python mode is refused before a draft exists, and a draft +/// whose cell sheet protection locks while it is open is dropped with the warning, as an ordinary +/// edit's is, so no draft is left that can never be entered and blocks Save (#2945). +#[test] +fn python_mode_leaves_no_draft_on_a_locked_cell() { + let mut s = blank(); + s.execute("python.set", json!({"cell": "A1", "code": "1"})).unwrap(); + s.execute("python.set", json!({"cell": "B1", "code": "2"})).unwrap(); + s.execute("home.formatCells", json!({"range": "B1", "style": {"protection": {"locked": false}}})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + let mut h = harness(s); + shortcut(&mut h); + assert_eq!(mode(&h), None, "no draft on the locked A1"); + assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("protected sheet")), "{:?}", h.state().message); + h.state_mut().run("ui.message.dismiss", json!({})).unwrap(); + // The unlocked B1 edits; then protection locks it while the draft is open. + h.state_mut().run("selection.set", json!({"range": "B1"})).unwrap(); + h.run_steps(2); + shortcut(&mut h); + assert_eq!(mode(&h), Some(true)); + type_text(&mut h, " + 1"); + h.state_mut().run("review.unprotectSheet", json!({})).unwrap(); + h.state_mut().run("home.formatCells", json!({"range": "B1", "style": {"protection": {"locked": true}}})).unwrap(); + h.state_mut().run("review.protectSheet", json!({})).unwrap(); + ctrl_enter(&mut h); + assert_eq!(mode(&h), None, "the draft is dropped, not left open"); + assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("protected sheet"))); + assert_eq!(code(&h, "B1").as_deref(), Some("2")); + assert!(h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).is_ok()); +} From 6ed56683bf79281957752ae26b66b4982d850fcf Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:44:02 -0400 Subject: [PATCH 04/10] i18n: Python Mode in every catalog The name of the PY badge beside fx, which turns Python mode on and off, in all 18 interface catalogs. cargo xtask i18n-report doesn't list it as missing. --- crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/el.rs | 1 + crates/ui-egui/src/i18n/es.rs | 1 + crates/ui-egui/src/i18n/fr.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ko.rs | 1 + crates/ui-egui/src/i18n/nl.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt.rs | 1 + crates/ui-egui/src/i18n/ro.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/sr.rs | 1 + crates/ui-egui/src/i18n/tr.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/i18n/vi.rs | 1 + crates/ui-egui/src/i18n/zh.rs | 1 + 18 files changed, 18 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 3ba6cb732..07e162f5e 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -144,6 +144,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Show Formulas" => "Zobrazit vzorce", "Error Checking" => "Kontrola chyb", "Circular References" => "Cyklické odkazy", + "Python Mode" => "Režim Pythonu", "Evaluate Formula" => "Vyhodnotit vzorec", "Watch Window" => "Okno sledování", "Calculation\nOptions" => "Možnosti\nvýpočtu", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 420214ffb..46c50c839 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -148,6 +148,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Show Formulas" => "Formeln anzeigen", "Error Checking" => "Fehlerüberprüfung", "Circular References" => "Zirkelbezüge", + "Python Mode" => "Python-Modus", "Evaluate Formula" => "Formel auswerten", "Watch Window" => "Überwachungsfenster", "Calculation\nOptions" => "Berechnungs-\noptionen", diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index cbc043ece..5165b76ab 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -143,6 +143,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Show Formulas" => "Εμφάνιση τύπων", "Error Checking" => "Έλεγχος σφαλμάτων", "Circular References" => "Κυκλικές αναφορές", + "Python Mode" => "Λειτουργία Python", "Evaluate Formula" => "Αξιολόγηση τύπου", "Watch Window" => "Παράθυρο παρακολούθησης", "Calculation\nOptions" => "Επιλογές\nυπολογισμού", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index f3df58b04..036e13f32 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -147,6 +147,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Show Formulas" => "Mostrar fórmulas", "Error Checking" => "Comprobación de errores", "Circular References" => "Referencias circulares", + "Python Mode" => "Modo Python", "Evaluate Formula" => "Evaluar fórmula", "Watch Window" => "Ventana Inspección", "Calculation\nOptions" => "Opciones\nde cálculo", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index e44696eb1..c0ae99487 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -158,6 +158,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Show Formulas" => "Afficher les formules", "Error Checking" => "Vérification des erreurs", "Circular References" => "Références circulaires", + "Python Mode" => "Mode Python", "Evaluate Formula" => "Évaluation de formule", "Watch Window" => "Fenêtre Espion", "Calculation\nOptions" => "Options de\ncalcul", diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 599b9c2a5..b26c6dfb3 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -151,6 +151,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Show Formulas" => "Mostra formule", "Error Checking" => "Controllo errori", "Circular References" => "Riferimenti circolari", + "Python Mode" => "Modalità Python", "Evaluate Formula" => "Valuta formula", "Watch Window" => "Finestra controlli", "Calculation\nOptions" => "Opzioni di\ncalcolo", diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 65015dfe0..f3880de6c 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -155,6 +155,7 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Show Formulas", "数式の表示"), ("Error Checking", "エラーチェック"), ("Circular References", "循環参照"), + ("Python Mode", "Python モード"), ("Evaluate Formula", "数式の検証"), ("Watch Window", "ウォッチ ウィンドウ"), ("Calculation\nOptions", "計算方法の設定"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 63159f7db..8ea0ce6be 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -153,6 +153,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Show Formulas" => "수식 표시", "Error Checking" => "오류 검사", "Circular References" => "순환 참조", + "Python Mode" => "Python 모드", "Evaluate Formula" => "수식 계산", "Watch Window" => "감시 창", "Calculation\nOptions" => "계산\n옵션", diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index f45fe10a0..ca346faab 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -152,6 +152,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Show Formulas" => "Formules weergeven", "Error Checking" => "Foutopsporing", "Circular References" => "Kringverwijzingen", + "Python Mode" => "Python-modus", "Evaluate Formula" => "Formule evalueren", "Watch Window" => "Bewakingsvenster", "Calculation\nOptions" => "Berekenings-\nopties", diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index ae4639760..84efd43fd 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -147,6 +147,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Show Formulas" => "Pokaż formuły", "Error Checking" => "Sprawdzanie błędów", "Circular References" => "Odwołania cykliczne", + "Python Mode" => "Tryb Python", "Evaluate Formula" => "Szacuj formułę", "Watch Window" => "Okno czujki", "Calculation\nOptions" => "Opcje\nobliczania", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 32b38a953..d9ca9b2c0 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -150,6 +150,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Show Formulas" => "Exibir Fórmulas", "Error Checking" => "Verificação de Erros", "Circular References" => "Referências Circulares", + "Python Mode" => "Modo Python", "Evaluate Formula" => "Avaliar Fórmula", "Watch Window" => "Janela de Inspeção", "Calculation\nOptions" => "Opções de\nCálculo", diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index f92555df2..080f2075d 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -154,6 +154,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Show Formulas" => "Afișare formule", "Error Checking" => "Verificare erori", "Circular References" => "Referințe circulare", + "Python Mode" => "Mod Python", "Evaluate Formula" => "Evaluare formulă", "Watch Window" => "Fereastră de urmărire", "Calculation\nOptions" => "Opțiuni de\ncalcul", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index f8d46d134..5bd588e42 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -153,6 +153,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Show Formulas" => "Показать формулы", "Error Checking" => "Проверка ошибок", "Circular References" => "Циклические ссылки", + "Python Mode" => "Режим Python", "Evaluate Formula" => "Вычислить формулу", "Watch Window" => "Окно контрольных значений", "Calculation\nOptions" => "Параметры\nвычислений", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index 4ff6aa4ce..4fb4a749e 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -148,6 +148,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Show Formulas" => "Prikaži formule", "Error Checking" => "Provera grešaka", "Circular References" => "Kružne reference", + "Python Mode" => "Python režim", "Evaluate Formula" => "Proceni formulu", "Watch Window" => "Prozor za praćenje", "Calculation\nOptions" => "Opcije\nizračunavanja", diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index f09e64e07..a5b8e583d 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -146,6 +146,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Show Formulas" => "Formülleri Göster", "Error Checking" => "Hata Denetimi", "Circular References" => "Döngüsel Başvurular", + "Python Mode" => "Python Modu", "Evaluate Formula" => "Formülü Değerlendir", "Watch Window" => "Gözcü Penceresi", "Calculation\nOptions" => "Hesaplama\nSeçenekleri", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 699a22288..3f5ca2199 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -149,6 +149,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Show Formulas" => "Показати формули", "Error Checking" => "Перевірка помилок", "Circular References" => "Циклічні посилання", + "Python Mode" => "Режим Python", "Evaluate Formula" => "Обчислити формулу", "Watch Window" => "Окно контрольних значень", "Calculation\nOptions" => "Параметри\nобчислень", diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index 9063b7d76..1d9546c63 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -155,6 +155,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Show Formulas" => "Hiện công thức", "Error Checking" => "Kiểm tra lỗi", "Circular References" => "Tham chiếu vòng", + "Python Mode" => "Chế độ Python", "Evaluate Formula" => "Đánh giá công thức", "Watch Window" => "Cửa sổ theo dõi", "Calculation\nOptions" => "Tùy chọn\ntính toán", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index a95f7684d..c4d81884c 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -153,6 +153,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Show Formulas" => "显示公式", "Error Checking" => "错误检查", "Circular References" => "循环引用", + "Python Mode" => "Python 模式", "Evaluate Formula" => "公式求值", "Watch Window" => "监视窗口", "Calculation\nOptions" => "计算\n选项", From 3bec72f64321433c222f6282979e461d2d358c8a Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:45:09 -0400 Subject: [PATCH 05/10] Docs: Python mode in the formula bar (UI parity, functions, architecture, control protocol, roadmap) ui-parity.md gains a Python mode row and a shortcut row for Ctrl+Alt+Shift+P with its Mac form. function-parity.md's Python in cells row names Python mode. architecture.md describes Python mode on the shared editor, code_field and its stop keys, and drafts that follow their sheet by its identity. control-protocol.md lists ui.pythonMode, the editor's python flag in ui.inspect, and when ui.inspect leaves a draft out. ROADMAP.md records the progress. --- ROADMAP.md | 4 +++- docs/architecture.md | 19 ++++++++++++++++++- docs/control-protocol.md | 3 ++- docs/function-parity.md | 5 +++-- docs/ui-parity.md | 5 ++++- 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3674b593c..dbc53cdbd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** minor (Python cells stored and kept in files; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** minor (Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-11-01 | Python mode in the formula bar: Ctrl+Alt+Shift+P, `=PY` then Tab, or editing a Python cell edits its code in the formula bar, with Enter for a new line, Tab to indent and Ctrl+Enter to commit through `python.set`. The code still doesn't run | | 2026-11-01 | Python cells as data: `python.set` stores code in a cell, Python cells copy, move, fill and undo like formulas, and `.xlsx` reads and writes Python cells as [MS-XLSX] specifies (`_xlfn._xlws.PY(index,0 or 1)`, the code in the workbook's Python part) with cached values and spills, so such workbooks open with their results; nothing runs Python (new code shows `#BLOCKED!`), and opening a file limits code size, result size and spill ranges | | 2026-11-01 | XLSX keeps the first visible workbook window's active sheet and display settings together, falling back to the first view when all are hidden. Tab ratios are normalized to 0–1000 on import and export; invalid numeric text uses 600. Synthetic round trips cover isolated attributes, missing views, and active tabs after sheet deletion/reordering; firstSheet remains separate #2264 work (#3057). | | 2026-10-31 | Esc in File › Info, Print or Options first leaves the text field; the next Esc returns to the sheet (#3035) | @@ -251,6 +252,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Progress log: Python mode in the formula bar | | 2026-11-01 | minor | Progress log: Python cells as data | | 2026-11-01 | trivial | Workbook views: selected window settings stay together, tab ratios are bounded, and regressions cover isolated settings and sheet edits; readiness estimates unchanged (#3057) | | 2026-10-31 | minor | File-page text fields keep the view open when Esc cancels an edit (#3035) | diff --git a/docs/architecture.md b/docs/architecture.md index c3373edf5..d85613834 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Python cells as data; earlier: recently used functions kept in the preferences; Function Library module; accent icons; earlier: the hidden names that hold macros) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Python mode in the formula bar; earlier: Python cells as data; earlier: recently used functions kept in the preferences; Function Library module; accent icons; earlier: the hidden names that hold macros) · **Target:** Microsoft Excel (Microsoft 365) How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md). @@ -178,6 +178,22 @@ bound what a file can make the app hold (result size, spill ranges, total rebuil size). This build runs no Python: `python.set` stores code, and new or changed code shows `#BLOCKED!`. +In ui-egui, Python mode is a state of the shared cell editor (`EditState::python`) that edits in +the formula bar only, through `code_editor::code_field`: the code editor's in-order key handling +(`apply_in_order_until`) with stop keys for commit, cancel and leaving the mode. A locked cell on +a protected sheet is refused before a draft exists. Committing a Python edit +(`commit_python_edit`) runs no command when the code is unchanged (no undo step), and none when +sheet protection locked the cell while the draft was open (the draft is dropped with the warning). +With all code deleted it runs `cell.set` with empty input on a Python cell, which clears it, and +nothing on any other cell. Any other commit runs `python.set`; when that refuses, the message +shows and the edit stays open. Drafts, Python or not, follow their sheet by its runtime identity +(`Sheet::uid`, `model::SheetUid`, found again by `EditState::sheet_in`), so a sheet inserted, +deleted or moved while one is open never takes it. After every command the open draft is pointed +at its sheet again, so it is drawn, pointed from and committed there, and its point-mode anchor +(held by sheet index) is let go when the sheet order changed. A draft is dropped then, with a +message, when its sheet is gone, or when it holds Python code (in Python mode, or begun on a Python +cell) that sheet protection now hides, as the Python editor window closes then. + ## Agent control - Desktop control channel: `gridcraft --control 7979`, JSON lines on localhost @@ -203,6 +219,7 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Python mode in the formula bar: Python mode on the shared editor, `code_field` and its stop keys; drafts follow their sheet by `model::SheetUid` | | 2026-11-01 | minor | Python cells as data: `model::PyCode`, the file forms, calc laying out kept results, the limits on open, and what copying, filling and sorting do to `xl()` references; edits that would push code past the limit are refused | | 2026-10-31 | minor | Recently used functions (`recent.rs`, `Prefs::recent_functions`) behind `formulas.recentlyUsed` | | 2026-10-31 | minor | Function Library module (`function_library.rs`); accent icons (`icons::paint_accent`, `widgets::small_button_accent`) | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3dee729d3..4978b409b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -58,13 +58,14 @@ connection; nothing sent after it runs. Blank lines are skipped. | `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, grid rect, scroll, perf, `windows` (as `window.list`) and `activeWindow` | +| `ui.inspect` | | UI state: ribbon tab, editor (`python` is true in Python mode; a draft in Python mode or of a Python cell has `text` null and `hidden` true while that cell's code is hidden on a protected sheet, or once the draft's sheet is gone; the sheet is found by its identity, whatever was inserted, deleted or moved before it), open dialog, Backstage page, message, grid rect, scroll, perf, `windows` (as `window.list`) and `activeWindow` | | `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.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.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`) | diff --git a/docs/function-parity.md b/docs/function-parity.md index 255d7dd0a..7e730d78c 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-11-01 · **Change:** minor (the Python errors `#PYTHON!`, `#BUSY!`, `#TIMEOUT!` and `#BLOCKED!`, and a Python in cells row; earlier: YEARFRAC basis 0 February-end rule, MOD quotient limit, PERCENTRANK significance; earlier: edge cases matched to Excel: base conversions, BITLSHIFT/BITRSHIFT, UNICHAR noncharacters, UPPER/LOWER/PROPER casing, FIXED/DOLLAR output, F/T/GAMMA/WEIBULL/BETA distributions, BINOM trial cap, HARMEAN; earlier: ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: IMSQRT reproduces the stray real part Excel gives for negative reals) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-11-01 · **Change:** minor (Python cells are edited in the formula bar; earlier: the Python errors `#PYTHON!`, `#BUSY!`, `#TIMEOUT!` and `#BLOCKED!`, and a Python in cells row; earlier: YEARFRAC basis 0 February-end rule, MOD quotient limit, PERCENTRANK significance; earlier: edge cases matched to Excel: base conversions, BITLSHIFT/BITRSHIFT, UNICHAR noncharacters, UPPER/LOWER/PROPER casing, FIXED/DOLLAR output, F/T/GAMMA/WEIBULL/BETA distributions, BINOM trial cap, HARMEAN; earlier: ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: IMSQRT reproduces the stray real part Excel gives for negative reals) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -98,7 +98,7 @@ This improves series preparation; it does not change the model-fitting or season | Localized function names and `;` separators | **missing** (#45); en-US only | | Long flat operator chains (`=A1+A2+…`, `-`, `*`, `/`, `^`, `&`, comparisons) | done: no operand limit below Excel's 8,192-character formula limit (Excel, observed: `=1+1+…` with 4,096 operands is exactly 8,192 characters with the `=` and calculates; one more character is refused). Evaluated (#484), printed (#628), stored, copied, adjusted, saved and reopened without recursing once per operand, on a 1 MiB stack (#916); written to XLSX as plain formulas, not dynamic arrays; Spanish entry accepts them | | Error values | the seven classic errors plus `#SPILL!`, `#CALC!`, `#GETTING_DATA`, and the Python errors `#PYTHON!`, `#BUSY!`, `#TIMEOUT!`, `#BLOCKED!` done; `#FIELD!`, `#CONNECT!`, `#UNKNOWN!` missing (minor: they come from data types and cloud features). `ERROR.TYPE` gives 11 for `#BLOCKED!` as Excel does; Microsoft documents no number for `#PYTHON!`, `#BUSY!` or `#TIMEOUT!`, and GridCraft gives them 19, 20 and 21 | -| Python in cells | partial: Python cells are stored, edited (`python.set`), copied, moved and saved, and Excel's `_xlfn._xlws.PY` cells open with their cached results; the code doesn't run, so new or changed code shows `#BLOCKED!`. Typing `=PY(` is refused with a pointer to `python.set`, so `PY` stays counted as missing above | +| Python in cells | partial: Python cells are stored, edited (`python.set`, and Python mode in the formula bar: Ctrl+Alt+Shift+P or `=PY` then Tab), copied, moved and saved, and Excel's `_xlfn._xlws.PY` cells open with their cached results; the code doesn't run, so new or changed code shows `#BLOCKED!`. Typing `=PY(` and Enter is refused with a pointer to `python.set`, so `PY` stays counted as missing above | ## Depth (estimated) @@ -134,6 +134,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Python in cells row: Python mode in the formula bar | | 2026-11-01 | minor | Python in cells (stored, not run): `#PYTHON!`, `#BUSY!`, `#TIMEOUT!` and `#BLOCKED!` done, with their `ERROR.TYPE` numbers; `PY` still counted as missing | | 2026-10-31 | minor | BIN2*, HEX2* and OCT2* reject surrounding spaces and tabs in source strings with `#NUM!`, matching Excel; plain unpadded values remain valid | | 2026-10-31 | minor | DEC2BIN, DEC2HEX and DEC2OCT validate `places` before returning negative two's-complement output, and Boolean places return `#VALUE!`, matching Excel | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a0236a79f..5ce30c59b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Python cells: Python mode in the formula bar; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -25,6 +25,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Formula bar with Name Box, expand, function hints | yes | yes; grows with its content from one to five lines (Alt+Enter, wrapped text, or viewing a multi-line cell), then scrolls with the caret in view, the Name Box and buttons staying on the first line; the chevron pins it at five; the in-cell editor shows every line | done; no drag-to-resize handle | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | Ctrl-drag (Cmd or Option on macOS) drops a copy (#971); active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Formula bar with Name Box, expand, function hints | yes | yes | done; dynamic-array spill children show the source formula muted and read-only, using current spill ownership | +| Python cells: Python mode | yes | yes (11-01): Ctrl+Alt+Shift+P (Cmd+Option+Shift+P on a Mac), `=PY` then Tab, or editing a Python cell edits the code in the formula bar's code editor, with a PY badge beside fx; Enter starts a new line with the indentation, Tab and Shift+Tab indent, Ctrl+Enter (Cmd+Enter) puts the code in the cell through `python.set`, Esc cancels. Deleting all the code and committing clears a Python cell, as an empty entry clears any other cell. The formula bar shows while a Python edit is open, also when View › Formula Bar hides it. On a protected sheet, a Python cell formatted Hidden shows no code in the formula bar and starts no edit, as a hidden formula shows none | this build stores Python code and doesn't run it: new or changed code shows `#BLOCKED!`; no `xl()` pointing on the sheet yet | | Formulas › Error Checking arrow: Circular References | yes | yes: the button opens Error Checking and its arrow opens a menu with Error Checking… and Circular References, which lists a cell on each circular reference as `Sheet!A1`, up to 100, and selects the one picked, on its own sheet (unavailable when there are none) | done; the Error Checking… item and the limit of 100 are GridCraft's own | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); New sheet (+) right after the last tab, the strip capped at 60% of the bar (#943); manual wheel/trackpad browsing preserved; next/previous skips Hidden and VeryHidden sheets and retains the current sheet and selection when no visible destination exists; rename focus bug on macOS (#145) | @@ -136,6 +137,7 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | Shift+F3, Ctrl+F3, Ctrl+Shift+F3 | Shift+F3, Cmd+F3 (Define Name), Cmd+Shift+F3 | Insert Function, Name Manager, Create from Selection | **added** | | F5, Shift+F5 (Mac), F7, Shift+F9, Ctrl+Alt+F9, F12, Ctrl+F4 | same | Go To, Find, Spelling, calculate sheet / all, Save As, close | **added** | | Shift+F10, Alt+Shift+F10, Ctrl+F1 | Shift+F10, Cmd+Option+R | context menu, Error Checking, ribbon | **added** | +| Ctrl+Alt+Shift+P | Cmd+Option+Shift+P | Python mode on or off | **added** | | F3 | not checked | Paste Name dialog, only when the workbook has names: OK puts the name at the caret of the formula being edited (or starts `=Name`), Paste List writes each name and what it refers to from the active cell (`formulas.pasteName`) | **added** | | F1, F4 (repeat), F6 / Shift+F6, F8 / Shift+F8, F11, End mode | same | help, Repeat, pane cycling, extend/add mode, chart sheet | missing (no such feature yet) | | Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | Quick Analysis, Function Arguments, formula bar height, tooltips | missing | @@ -196,6 +198,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-10-31 | minor | Esc in a File-page text field leaves the field and cancels editing; the next Esc returns to the sheet (#3035) | | 2026-10-31 | minor | Recently Used lists the functions most recently entered in a formula from any source, kept with the preferences; the Insert Function dialog is titled as in Excel, its category list (Most Recently Used, All, each category) works, and its category column follows the widest name shown | | 2026-10-31 | minor | Formulas › Function Library: Insert Function, the AutoSum split button and three columns of labelled small buttons with category colours; category menus end with Insert Function…; More Functions cascades; Home › Editing shares the AutoSum menu; MR key tip for Recently Used | From b5acb393565bed5e91c8f3ecabec0f6ab4d64269 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:33:10 -0500 Subject: [PATCH 06/10] UI: a draft typed over a hidden Python cell stays open An ordinary draft typed over an unlocked, hidden Python cell on a protected sheet holds only what was typed. follow_open_draft dropped it after any command, and ui.inspect hid its text, because both checked whether the cell was a Python cell. They now check whether the draft holds code: a draft in Python mode, or one that began on a Python cell's code (py_cell). Test: a_draft_typed_over_a_hidden_python_cell_stays_open_through_commands (fails without the fix: the draft was dropped). --- crates/ui-egui/src/app/editing.rs | 12 ++++++------ crates/ui-egui/src/control.rs | 20 +++++++++++--------- crates/ui-egui/src/control/tests.rs | 25 +++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/app/editing.rs b/crates/ui-egui/src/app/editing.rs index 903e302ac..8422e1538 100644 --- a/crates/ui-egui/src/app/editing.rs +++ b/crates/ui-egui/src/app/editing.rs @@ -220,18 +220,18 @@ impl SheetApp { /// drawn, pointed from and committed on its sheet, and the point-mode anchor, held by sheet /// index, is let go. A draft is dropped at once with a message when its sheet is gone, or when /// it holds Python code that sheet protection now hides (a draft in Python mode, or one that - /// began on a Python cell): neither editor shows hidden code, as the Python editor window - /// closes then too. + /// began on a Python cell's code): neither editor shows hidden code, as the Python editor + /// window closes then too. A draft typed over a Python cell holds only what was typed, so it + /// stays open. pub(crate) fn follow_open_draft(&mut self) { let Some(ed) = self.editor.as_ref() else { return }; let Some(d) = self.session.documents().iter().find(|d| d.uid == ed.doc) else { return }; let order = editor::sheet_order(&d.wb); let moved = ed.sheet_uid != 0 && order != ed.sheet_order; let found = if moved { ed.sheet_in(&d.wb) } else { Some(ed.sheet) }; - let hidden = found.and_then(|i| d.wb.sheet(i)).is_some_and(|sh| { - (ed.python || ed.py_cell || sh.cell(ed.cell).is_some_and(|c| c.py.is_some())) - && gridcraft_engine::display::formula_hidden(&d.wb, sh, ed.cell) - }); + let hidden = found + .and_then(|i| d.wb.sheet(i)) + .is_some_and(|sh| (ed.python || ed.py_cell) && gridcraft_engine::display::formula_hidden(&d.wb, sh, ed.cell)); let why = match found { None => Some(DRAFT_SHEET_GONE), Some(_) if hidden => Some(DRAFT_CODE_HIDDEN), diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d7919e60a..dddce9759 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -161,16 +161,18 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json { /// 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 any -/// draft of a Python cell, since leaving Python mode keeps the code as an ordinary draft. 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. +/// 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 = |sh: &gridcraft_engine::model::Sheet| e.python || sh.cell(e.cell).is_some_and(|c| c.py.is_some()); - let hidden = 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) => code(sh) && gridcraft_engine::display::formula_hidden(&d.wb, sh, e.cell), - None => e.python || e.py_cell, - }); + 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}) } diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 1183535e8..de507907a 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -1208,3 +1208,28 @@ fn a_draft_of_python_code_that_protection_hides_is_dropped_with_a_message() { execute(&mut app, &ctx, "review.protectSheet", json!({})); assert!(app.editor.as_ref().is_some_and(|e| e.text == "=2+2"), "a formula draft is unchanged"); } + +#[test] +fn a_draft_typed_over_a_hidden_python_cell_stays_open_through_commands() { + // An unlocked, hidden Python cell on a protected sheet: typing replaces it, as in Excel. The + // draft holds only what was typed, so a command neither drops it nor hides it from ui.inspect. + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + session.execute("python.set", json!({"cell": "B2", "code": "secret = 41"})).unwrap(); + session.execute("home.formatCells", json!({"range": "B2", "style": {"protection": {"hidden": true, "locked": false}}})).unwrap(); + session.execute("review.protectSheet", json!({})).unwrap(); + session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + let mut app = SheetApp::new(session, crate::Services::default()); + let ctx = egui::Context::default(); + call(&mut app, &ctx, "ui.edit.begin", json!({"text": "7"})); + execute(&mut app, &ctx, "home.insertSheet", json!({"name": "Other", "before": 1})); + assert!(app.editor.as_ref().is_some_and(|e| e.text == "7"), "the typed entry stays open"); + assert!(app.message.is_none(), "{:?}", app.message); + let after = inspected(&mut app, &ctx); + assert_eq!(after["editor"]["text"], "7"); + assert_eq!(after["editor"]["hidden"], false); + call(&mut app, &ctx, "ui.edit.commit", json!({"move": "none"})); + let b2 = app.session.active().and_then(|d| d.wb.sheet(0)).and_then(|sh| sh.cell(gridcraft_engine::core::CellRef::new(1, 1)).cloned()); + assert_eq!(b2.map(|c| (c.input_text(), c.py.is_some())), Some(("7".to_string(), false)), "the typed entry replaces the Python cell"); +} + From 5119fb5f5190d54fa6f23c2926d287bd43801d55 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:33:10 -0500 Subject: [PATCH 07/10] UI: Python mode starts on no draft typed inside a spill Python mode refuses a cell inside another cell's spill, but a draft typed on a spill child could still switch to it with Ctrl+Alt+Shift+P, the PY badge, ui.pythonMode or =PY then Tab, and Ctrl+Enter then wrote code into the spill. toggle_python_mode and the formula bar's switch now check the draft's own cell (draft_in_spill) and keep such a draft an ordinary entry. Tests: python_mode_does_not_start_on_a_draft_typed_inside_a_spill (control) and a_draft_typed_inside_a_spill_does_not_enter_python_mode (keys); both fail without the fix. --- crates/ui-egui/src/app/editing.rs | 11 +++++++++++ crates/ui-egui/src/control/tests.rs | 18 ++++++++++++++++++ crates/ui-egui/src/formula_bar.rs | 2 +- crates/ui-egui/tests/it/python_mode.rs | 19 +++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/app/editing.rs b/crates/ui-egui/src/app/editing.rs index 8422e1538..bd7a6ab5e 100644 --- a/crates/ui-egui/src/app/editing.rs +++ b/crates/ui-egui/src/app/editing.rs @@ -321,6 +321,9 @@ impl SheetApp { /// Ctrl+Alt+Shift+P (and the PY badge): Python mode on or off. An edit in progress keeps its /// text; with no edit, Python mode starts on the active cell. pub fn toggle_python_mode(&mut self) { + if self.editor.as_ref().is_some_and(|ed| !ed.python && self.draft_in_spill(ed)) { + return; + } match self.editor.as_mut() { Some(ed) if ed.python => ed.leave_python(), Some(ed) => ed.enter_python(), @@ -329,6 +332,14 @@ impl SheetApp { self.session.mode = gridcraft_engine::Mode::Edit; } + /// Whether a draft's cell lies inside another cell's spill. A draft typed there stays an + /// ordinary entry: Python mode starts on no cell inside a spill, as the formula bar's entry + /// paths don't. + pub(crate) fn draft_in_spill(&self, ed: &editor::EditState) -> bool { + let doc = self.session.documents().iter().find(|d| d.uid == ed.doc); + doc.and_then(|d| d.wb.sheet(ed.sheet)).is_some_and(|sh| sh.spill_anchor(ed.cell).is_some()) + } + /// 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/tests.rs b/crates/ui-egui/src/control/tests.rs index de507907a..7f876d35c 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -1233,3 +1233,21 @@ fn a_draft_typed_over_a_hidden_python_cell_stays_open_through_commands() { assert_eq!(b2.map(|c| (c.input_text(), c.py.is_some())), Some(("7".to_string(), false)), "the typed entry replaces the Python cell"); } +#[test] +fn python_mode_does_not_start_on_a_draft_typed_inside_a_spill() { + // Typing on a spill child is an ordinary entry; switching that draft to Python mode would let + // Ctrl+Enter write code into a cell another cell's result spills over, which every other way + // into Python mode refuses. + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + session.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap(); + session.execute("selection.set", json!({"cell": "A2"})).unwrap(); + 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})); + 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()); + assert_eq!(a2.map(|c| (c.input_text(), c.py.is_some())), Some(("5".to_string(), false))); +} diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 3f2d08a60..25178ce50 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -518,7 +518,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: show_editor_suggestions(ui, EditorSuggestion { app, id, resp: &resp, ed: &mut ed, galley: &output.galley, tokens: &t, mine }); let lines = output.galley.rows.len().max(1); let caret = Some(output.galley.pos_from_cursor(egui::text::CCursor::new(ed.caret)).translate(output.galley_pos.to_vec2())); - if to_python { + if to_python && !app.draft_in_spill(&ed) { ed.enter_python(); app.editor = Some(ed); app.session.mode = gridcraft_engine::Mode::Edit; diff --git a/crates/ui-egui/tests/it/python_mode.rs b/crates/ui-egui/tests/it/python_mode.rs index 6e88e39d4..79c0b079c 100644 --- a/crates/ui-egui/tests/it/python_mode.rs +++ b/crates/ui-egui/tests/it/python_mode.rs @@ -126,6 +126,25 @@ fn typing_py_then_tab_enters_python_mode_as_in_excel() { assert_eq!(mode(&h), None); } +/// A cell inside a spill: typing there is an ordinary entry, as on any spill child, and neither +/// `=PY` then Tab nor the shortcut turns that draft into code for the cell, as Python mode starts +/// on no cell inside a spill. +#[test] +fn a_draft_typed_inside_a_spill_does_not_enter_python_mode() { + let mut s = blank(); + s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap(); + s.execute("selection.set", json!({"cell": "A2"})).unwrap(); + let mut h = harness(s); + type_text(&mut h, "=PY"); + assert_eq!(mode(&h), Some(false), "typing starts an ordinary entry"); + send(&mut h, press(Key::Tab, Modifiers::NONE)); + assert_eq!(mode(&h), Some(false), "=PY then Tab"); + shortcut(&mut h); + assert_eq!(mode(&h), Some(false), "the shortcut"); + ctrl_enter(&mut h); + assert_eq!(code(&h, "A2"), None, "no code is written into the spill"); +} + #[test] fn enter_and_tab_edit_the_code_and_ctrl_enter_commits_through_python_set() { let mut h = harness(blank()); From 23e09713a915e576b0e9b3b13f195d0e223799c0 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:33:15 -0500 Subject: [PATCH 08/10] Docs: ui.inspect shows a draft typed over a hidden Python cell --- docs/control-protocol.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4978b409b..1f3056825 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -58,7 +58,7 @@ connection; nothing sent after it runs. Blank lines are skipped. | `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 (`python` is true in Python mode; a draft in Python mode or of a Python cell has `text` null and `hidden` true while that cell's code is hidden on a protected sheet, or once the draft's sheet is gone; the sheet is found by its identity, whatever was inserted, deleted or moved before it), open dialog, Backstage page, message, grid rect, scroll, perf, `windows` (as `window.list`) and `activeWindow` | +| `ui.inspect` | | UI state: ribbon tab, editor (`python` is true in Python mode; a draft in Python mode or of a Python cell's code has `text` null (a draft typed over a Python cell holds only what was typed and is shown) and `hidden` true while that cell's code is hidden on a protected sheet, or once the draft's sheet is gone; the sheet is found by its identity, whatever was inserted, deleted or moved before it), open dialog, Backstage page, message, grid rect, scroll, perf, `windows` (as `window.list`) and `activeWindow` | | `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 | From 94b292d5a8d08acc58725e124fd29a4ff3fc9196 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:04:23 -0500 Subject: [PATCH 09/10] 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. --- crates/ui-egui/src/app/commands.rs | 10 ++++++++-- crates/ui-egui/src/app/editing.rs | 17 ++++++++++++++++ crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/control/tests.rs | 7 +++++-- crates/ui-egui/tests/it/python_hidden.rs | 4 +++- crates/ui-egui/tests/it/python_mode.rs | 25 ++++++++++++++++++++++++ docs/control-protocol.md | 2 +- 7 files changed, 60 insertions(+), 7 deletions(-) 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`) | From 9c6dbfdfab528d16e4c9b02296826209991a654a Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:43:28 -0500 Subject: [PATCH 10/10] UI: the code field draws the equation editor's error mark again The merge of main brought #471's code_editor_marked, whose mark was drawn by the layouter inside the code editor; on this branch that layouter lives in code_field, which had no mark, so the merge did not compile. FieldOptions gains mark (None by default), code_editor_marked passes it, and code_field underlines it as before. Test: the_marked_editor_underlines_its_mark (fails when the field ignores the mark). --- crates/ui-egui/src/code_editor.rs | 7 +++++-- crates/ui-egui/src/code_editor/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/code_editor.rs b/crates/ui-egui/src/code_editor.rs index 67167a8b9..589c22e9a 100644 --- a/crates/ui-egui/src/code_editor.rs +++ b/crates/ui-egui/src/code_editor.rs @@ -583,7 +583,7 @@ pub fn code_editor_marked(ui: &mut Ui, id: egui::Id, text: &mut String, lang: La let max_lines = max_lines.clamp(1, 200); let mut response = None; egui::Frame::new().fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).corner_radius(4.0).inner_margin(4.0).show(ui, |ui| { - let options = FieldOptions { max_height: row_h * max_lines as f32, ..FieldOptions::new(lang) }; + let options = FieldOptions { max_height: row_h * max_lines as f32, mark: mark.clone(), ..FieldOptions::new(lang) }; response = Some(code_field(ui, id, text, &options).response); }); response.unwrap_or_else(|| ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover())) @@ -606,6 +606,8 @@ pub struct FieldOptions<'a> { pub caret: Option, /// The field's name for screen readers. pub name: &'a str, + /// A byte range underlined in the error colour (see [`underline`]). + pub mark: Option>, } impl FieldOptions<'_> { @@ -621,6 +623,7 @@ impl FieldOptions<'_> { // The editor is a generic code field, so the name is not part of the UI's translated // strings unless the caller gives one. name: "Code Editor", + mark: None, } } } @@ -685,7 +688,7 @@ pub fn code_field(ui: &mut Ui, id: egui::Id, text: &mut String, options: &FieldO let danger = t.danger; let mut layouter = |ui: &Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| { let mut job = layout_job(buf.as_str(), lang, &self::font(), plain, &syntax, wrap_width); - if let Some(m) = mark.clone() { + if let Some(m) = options.mark.clone() { underline(&mut job, m, danger); } ui.fonts_mut(|f| f.layout_job(job)) diff --git a/crates/ui-egui/src/code_editor/tests.rs b/crates/ui-egui/src/code_editor/tests.rs index 4cd93bd1b..b3393aec5 100644 --- a/crates/ui-egui/src/code_editor/tests.rs +++ b/crates/ui-egui/src/code_editor/tests.rs @@ -459,3 +459,24 @@ fn a_repeat_pass_of_the_frame_counts_as_the_next_pass() { h.run_steps(1); assert_eq!(h.state().0, " a\n b"); } + +/// Whether a frame drew text with an underline. +fn draws_underline(mark: Option>) -> bool { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let mut text = String::from("x^{2"); + code_editor_marked(ui, egui::Id::unique("marked"), &mut text, Lang::Latex, 3, mark.clone()); + }); + out.textures_delta.clear(); + out.shapes.iter().any(|s| match &s.shape { + egui::Shape::Text(t) => t.galley.job.sections.iter().any(|sec| sec.format.underline.width > 0.0), + _ => false, + }) +} + +#[test] +fn the_marked_editor_underlines_its_mark() { + // The equation editor marks an error's position; the code field draws it. + assert!(draws_underline(Some(3..4))); + assert!(!draws_underline(None)); +}