diff --git a/ROADMAP.md b/ROADMAP.md index fb9985c72..9acd2e13f 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-02 · **Change:** minor (Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; 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-02 · **Change:** minor (Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; 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-02 | 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-02 | 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-02 | Equations: Insert › Equation (a split button with a gallery of examples) and an editor with quick-insert buttons, the error underlined at its position and a live preview; `insert.equation` and `equation.edit`; LaTeX laid out by RaTeX inside the new `gridcraft-math` crate, capped and panic-guarded, and cached on the shape so that painting never runs the layout engine; xlsx writes Office Math with a plain-text fallback and the source in `descr`, and reads Office Math back into LaTeX; PDF prints equations with the standard Symbol font and `[?]` for anything else | | 2026-11-02 | Solver writes a Sensitivity report: Simplex LP shadow prices, reduced costs and allowable ranges from the final tableau; GRG Nonlinear reduced gradients and Lagrange multipliers from difference gradients, at a Karush-Kuhn-Tucker point only; Solver Results offers it where it applies (no score change) | @@ -255,6 +256,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Progress log: Python mode in the formula bar | | 2026-11-02 | minor | Progress log: Python cells as data | | 2026-11-02 | minor | Progress log: equations (Insert › Equation, the editor, gridcraft-math) | | 2026-11-02 | minor | What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear (no score change) | 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/cmd/sheet/tabs.rs b/crates/engine/src/cmd/sheet/tabs.rs index ced855297..0c3925d22 100644 --- a/crates/engine/src/cmd/sheet/tabs.rs +++ b/crates/engine/src/cmd/sheet/tabs.rs @@ -152,6 +152,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/lib.rs b/crates/engine/src/lib.rs index 18f941c0d..6f61b239e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -795,7 +795,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/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/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)}); diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index a6a27faca..35cfa315f 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 3ee16a64f..496564dc2 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(), @@ -593,6 +630,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"); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 3d1dc73c4..9d190083e 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 5c3faddc9..a2f47c67f 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -246,6 +246,21 @@ 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. 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); + 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)})) + } "ui.message.dismiss" => { self.message = None; Ok(Json::Null) @@ -293,6 +308,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..8682a435c 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'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) && 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,125 @@ 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) { + 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(), + None => return self.begin_python_edit(), + } + 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()) + } + + /// 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 + /// 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 53ac8ddbb..589c22e9a 100644 --- a/crates/ui-egui/src/code_editor.rs +++ b/crates/ui-egui/src/code_editor.rs @@ -410,6 +410,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 @@ -426,6 +428,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; @@ -433,10 +451,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; @@ -546,18 +579,91 @@ pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max /// [`code_editor`] with the byte range `mark` underlined in the error colour (see [`underline`]). pub fn code_editor_marked(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max_lines: usize, mark: Option>) -> 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, 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())) +} + +/// 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, + /// A byte range underlined in the error colour (see [`underline`]). + pub mark: Option>, +} + +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", + mark: None, + } + } +} + +/// 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() { @@ -565,7 +671,7 @@ pub fn code_editor_marked(ui: &mut Ui, id: egui::Id, text: &mut String, lang: La 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)))); @@ -575,46 +681,52 @@ pub fn code_editor_marked(ui: &mut Ui, id: egui::Id, text: &mut String, lang: La if !applied.held.is_empty() { hold(ui, id, applied.held); } + stop = applied.stop; } let syntax = t.syntax; let plain = t.text; 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)) }; - 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 69363100c..b3393aec5 100644 --- a/crates/ui-egui/src/code_editor/tests.rs +++ b/crates/ui-egui/src/code_editor/tests.rs @@ -336,6 +336,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![ @@ -424,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)); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c2bd46263..3a29f6d1b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -146,7 +146,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})), @@ -162,6 +162,24 @@ 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 a +/// draft that began on a Python cell's code, since leaving Python mode keeps the code as an +/// ordinary draft; a draft typed over a Python cell holds only what was typed. The sheet is the +/// draft's own, found by identity: a sheet inserted, deleted or moved in the same batch puts +/// another at its index. With its sheet gone, such a draft is left out too. +fn editor_json(app: &SheetApp, e: &crate::editor::EditState) -> Json { + let code = e.python || e.py_cell; + let hidden = code + && app.session.documents().iter().find(|d| d.uid == e.doc).is_some_and(|d| match e.sheet_in(&d.wb).and_then(|i| d.wb.sheet(i)) { + Some(sh) => gridcraft_engine::display::formula_hidden(&d.wb, sh, e.cell), + None => true, + }); + let text = if hidden { Json::Null } else { json!(e.text) }; + json!({"cell": e.cell.a1(), "text": text, "hidden": hidden, "formulaBar": e.from_formula_bar, "point": e.can_point(), "python": e.python}) +} + /// `ui.object.select {id?, kind?}`: selects the object with that id on the active sheet (its kind /// is found from the id when left out, and must match when given); no `id` clears the selection. /// Replies with the object and its rect in window points, for `ui.click` and `ui.drag`. @@ -218,6 +236,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}; 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 a680279e5..0f10348ea 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -977,3 +977,280 @@ 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}; 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}"); + 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"); +} + +#[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"); +} + +#[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"})); + 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()); + assert_eq!(a2.map(|c| (c.input_text(), c.py.is_some())), Some(("5".to_string(), false))); +} 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..25178ce50 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 && !app.draft_in_spill(&ed) { + 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/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 42f812339..4062f4a45 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -147,6 +147,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Error Checking" => "Kontrola chyb", "Trace Error" => "Sledovat chybu", "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 7c67a444e..2a70f2f0a 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -161,6 +161,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Error Checking" => "Fehlerüberprüfung", "Trace Error" => "Spur zum Fehler", "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 347723e8c..4fc7f5bac 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -146,6 +146,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Error Checking" => "Έλεγχος σφαλμάτων", "Trace Error" => "Εντοπισμός σφάλματος", "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 fcbd8f168..92d22aa66 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -150,6 +150,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Error Checking" => "Comprobación de errores", "Trace Error" => "Rastrear error", "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 5fbcc0b1f..ff678c9da 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -161,6 +161,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Error Checking" => "Vérification des erreurs", "Trace Error" => "Repérer une erreur", "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 0ee594233..e60f722a7 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -154,6 +154,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Error Checking" => "Controllo errori", "Trace Error" => "Individua errore", "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 813ee9c27..3aea3f6ce 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -158,6 +158,7 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Error Checking", "エラーチェック"), ("Trace Error", "エラーのトレース"), ("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 15dad1c87..194eb26ee 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -156,6 +156,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Error Checking" => "오류 검사", "Trace Error" => "오류 추적", "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 f3966cb87..485de1b00 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -155,6 +155,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Error Checking" => "Foutopsporing", "Trace Error" => "Fout traceren", "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 8f69d4b99..2b487d1f6 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -150,6 +150,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Error Checking" => "Sprawdzanie błędów", "Trace Error" => "Śledź błąd", "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 dfa82d5c5..e28dafcaa 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -153,6 +153,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Error Checking" => "Verificação de Erros", "Trace Error" => "Rastrear Erro", "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 d8eceafec..ee35c4dca 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -157,6 +157,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Error Checking" => "Verificare erori", "Trace Error" => "Trasare eroare", "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 f972c6d1b..eaefd5831 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -156,6 +156,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Error Checking" => "Проверка ошибок", "Trace Error" => "Источник ошибки", "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 cb46312bb..f7b647090 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -151,6 +151,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Error Checking" => "Provera grešaka", "Trace Error" => "Prati grešku", "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 73dffbdf9..7b66509c1 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -149,6 +149,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Error Checking" => "Hata Denetimi", "Trace Error" => "Hatayı İzle", "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 ae98ce8a5..6c2cbada2 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -152,6 +152,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Error Checking" => "Перевірка помилок", "Trace Error" => "Джерело помилки", "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 de6b0da93..daadb1385 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -158,6 +158,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Error Checking" => "Kiểm tra lỗi", "Trace Error" => "Truy tìm 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 412e8040d..b73b116f1 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -156,6 +156,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Error Checking" => "错误检查", "Trace Error" => "追踪错误", "Circular References" => "循环引用", + "Python Mode" => "Python 模式", "Evaluate Formula" => "公式求值", "Watch Window" => "监视窗口", "Calculation\nOptions" => "计算\n选项", 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 3233e7443..444b44ce7 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, /// Trace arrows on the sheet: precedents and dependents, and the links Trace Error follows /// between cells showing an error. Sheet colours. pub sheet_trace_arrow: Color32, @@ -172,6 +179,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 { @@ -251,6 +260,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, sheet_trace_arrow: SHEET_TRACE_ARROW, sheet_trace_error: SHEET_TRACE_ERROR, } @@ -412,6 +425,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 eb0a63c33..14f862ae0 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -87,6 +87,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..228d0cfab --- /dev/null +++ b/crates/ui-egui/tests/it/python_hidden.rs @@ -0,0 +1,139 @@ +//! 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"); + 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); + 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..c2516366b --- /dev/null +++ b/crates/ui-egui/tests/it/python_mode.rs @@ -0,0 +1,453 @@ +//! 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); +} + +/// 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()); + 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()); +} + +#[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 = { + 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()); +} diff --git a/docs/architecture.md b/docs/architecture.md index 08f249a02..68639541e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (Python cells as data; earlier: equations: the `math` crate and the layout cache; 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-02 · **Change:** minor (Python mode in the formula bar; earlier: Python cells as data; earlier: equations: the `math` crate and the layout cache; 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). @@ -181,6 +181,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. + ## Equations An equation is a shape (`ShapeKind::Equation`) whose `text` is its LaTeX source; it selects, @@ -220,6 +236,7 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in | Date | Change | Summary | |---|---|---| +| 2026-11-02 | 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-02 | 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-11-01 | trivial | Equations: the layout cache answers for a source over the cap by its length alone, without hashing it | | 2026-11-01 | minor | Equations: the `math` crate (L1), the layout cache and code editor marks | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index fd6493205..354e88e09 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, open dialog, Backstage page, message, selected object, 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, selected object, grid rect, scroll, perf, `windows` (as `window.list`) and `activeWindow` | | `ui.object.select` | `{id?, kind?}` | Selects a floating object on the active sheet and replies `{selected, rect}`, the rect in window points; with no `id` it clears the selection and replies `null` | | `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 | @@ -66,6 +66,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. 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`) | 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 21bad59e8..ba97eea4d 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-11-02 · **Change:** minor (Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; 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: Trace Error, Circular References | yes | yes: the button opens Error Checking and its arrow opens a menu with Error Checking…, Trace Error and Circular References. Trace Error (`formulas.traceError`, 11-01) follows the active cell's error back through the first reference showing the same error (in a range, its first cell showing it) to the cell it starts in, and draws red arrows along the way and blue ones from that cell's own references, as measured in Excel; on a cell without an error it draws nothing and the arrows shown stay. Circular References 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; a new trace replaces the arrows shown, where Excel adds to them, and there is no Remove Precedent Arrows | | 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-02 | 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-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | | 2026-11-02 | minor | Floating objects: one selection for charts, pictures, shapes and slicers, eight resize handles with matching cursors (right-to-left sheets included), Escape drops the selection | | 2026-11-01 | minor | Chart Design › Add Chart Element offers Data Table with/no legend keys and None; Format Chart Area has a Data Table section with visibility, legend keys, horizontal/vertical borders and outline. Both use engine commands. |