mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-03 00:41:56 -06:00
Merge pull request #2639 from prxnvt/pr/python-cell-ui
Python cells in the interface: formula bar Python mode and the output switch (no code runs)
This commit is contained in:
+3
-1
@@ -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) |
|
||||
|
||||
@@ -138,7 +138,7 @@ pub(super) fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
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<Option<PyCode>> {
|
||||
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<Option<bool>> {
|
||||
fn output_mode(cmd: &str, p: &Json, key: &str) -> Result<Option<bool>> {
|
||||
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<Json> {
|
||||
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<PyCode>) -> 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<Json> {
|
||||
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<Json> {
|
||||
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)}))
|
||||
}
|
||||
|
||||
@@ -152,6 +152,8 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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..)
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<usize> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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::<std::collections::BTreeSet<_>>().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())));
|
||||
}
|
||||
|
||||
@@ -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)});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>) -> 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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<egui::Event>,
|
||||
/// The caller's key the pass stopped at (see [`apply_in_order_until`]).
|
||||
pub stop: Option<egui::Event>,
|
||||
}
|
||||
|
||||
/// 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<egui::Event>, 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<egui::Event>,
|
||||
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<egui::Event>, 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<Range<usize>>) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let font = font();
|
||||
let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font));
|
||||
let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font()));
|
||||
let max_lines = max_lines.clamp(1, 200);
|
||||
let mut response = None;
|
||||
egui::Frame::new().fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).corner_radius(4.0).inner_margin(4.0).show(ui, |ui| {
|
||||
let options = FieldOptions { max_height: row_h * max_lines as f32, 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<usize>,
|
||||
/// The field's name for screen readers.
|
||||
pub name: &'a str,
|
||||
/// A byte range underlined in the error colour (see [`underline`]).
|
||||
pub mark: Option<Range<usize>>,
|
||||
}
|
||||
|
||||
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<egui::Event>,
|
||||
/// Laid-out rows of the text.
|
||||
pub lines: usize,
|
||||
/// The caret on screen, while the field has the focus.
|
||||
pub caret: Option<egui::Rect>,
|
||||
/// 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)]
|
||||
|
||||
@@ -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<std::ops::Range<usize>>) -> 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));
|
||||
}
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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::<usize>(), 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)));
|
||||
}
|
||||
|
||||
@@ -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<u64>,
|
||||
/// 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<String>,
|
||||
/// 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<usize> {
|
||||
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())?;
|
||||
|
||||
@@ -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<gridcraft_engine::co
|
||||
sh.spill_anchor(at)
|
||||
}
|
||||
|
||||
/// The active cell's Python cell (itself, or the anchor whose result spills over it): its sheet,
|
||||
/// cell and code.
|
||||
pub(crate) fn active_python(app: &SheetApp) -> Option<(usize, gridcraft_engine::core::CellRef, std::sync::Arc<gridcraft_engine::model::PyCode>)> {
|
||||
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<PythonKey> {
|
||||
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<Rect> {
|
||||
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<EditorAction>,
|
||||
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<EditorAction>, 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<EditorAction>, 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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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υπολογισμού",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", "計算方法の設定"),
|
||||
|
||||
@@ -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옵션",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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вычислений",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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обчислень",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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选项",
|
||||
|
||||
@@ -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<Shortcut> {
|
||||
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))) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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<Item = Event>) {
|
||||
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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Item = Event>) {
|
||||
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<bool> {
|
||||
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<String> {
|
||||
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());
|
||||
}
|
||||
+18
-1
@@ -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 |
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+4
-1
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user