diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 068be59a4..b25d63426 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -14,6 +14,8 @@ use super::{ mod clipboard; mod commands; mod editing; +#[cfg(test)] +pub(crate) use editing::{DRAFT_CODE_HIDDEN, DRAFT_SHEET_GONE}; mod files; mod fonts; mod input; @@ -221,7 +223,9 @@ impl SheetApp { } else { ribbon::show(self, ui); widgets::notice_bar(self, ui); - if self.session.prefs.formula_bar { + // Python mode edits only in the formula bar: it shows while a Python edit is open, + // also when the preference hides it. + if self.session.prefs.formula_bar || self.editor.as_ref().is_some_and(|e| e.python) { formula_bar::show(self, ui); } if self.ui.status_bar { diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index c7dcde0ff..b5e90e757 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -234,6 +234,15 @@ impl SheetApp { self.open_dialog(name, p.clone()); Ok(json!({"dialog": self.dialog.as_ref().map(|d| d.name())})) } + "ui.pythonMode" => { + // `{"on": true}` starts Python mode on the active cell (or switches the edit in + // progress to it), `{"on": false}` leaves it; without `on` it switches. + let on = self.editor.as_ref().is_some_and(|e| e.python); + if p.get("on").and_then(Json::as_bool).unwrap_or(!on) != on { + self.toggle_python_mode(); + } + Ok(json!({"on": self.editor.as_ref().is_some_and(|e| e.python)})) + } "ui.message.dismiss" => { self.message = None; Ok(Json::Null) @@ -280,6 +289,7 @@ impl SheetApp { } } } + self.follow_open_draft(); } /// Whether the active sheet of the active document is protected (drives the Protect/Unprotect diff --git a/crates/ui-egui/src/app/editing.rs b/crates/ui-egui/src/app/editing.rs index 61b2af1ff..903e302ac 100644 --- a/crates/ui-egui/src/app/editing.rs +++ b/crates/ui-egui/src/app/editing.rs @@ -1,4 +1,10 @@ -use super::{Json, SheetApp, clean_error, editor, formula_bar, formula_locale, json, now_ms}; +use super::{SheetApp, clean_error, editor, formula_bar, formula_locale, json, now_ms}; + +/// Why a draft whose sheet was deleted while it was open is dropped. +pub(crate) const DRAFT_SHEET_GONE: &str = "The sheet of the cell being edited was deleted, so the entry was discarded."; + +/// Why a draft of Python code that sheet protection hid while it was open is dropped. +pub(crate) const DRAFT_CODE_HIDDEN: &str = "Sheet protection now hides the code of the cell being edited, so the entry was discarded."; impl SheetApp { /// Starts editing the active cell. `text` replaces the content (typing) or pre-fills it. @@ -28,6 +34,22 @@ impl SheetApp { if text.is_none() && sh.cell_pictures.contains_key(&at) { return; } + // Editing a Python cell edits its code, in Python mode; typing replaces it as in Excel. + if text.is_none() + && let Some(py) = sh.cell(at).and_then(|c| c.py.as_deref()) + { + // Code hidden on a protected sheet isn't loaded into an edit, and a blank edit would + // write over it on commit: no edit starts. Typing still replaces the cell. + if gridcraft_engine::display::formula_hidden(&d.wb, sh, at) { + return; + } + let mut ed = editor::EditState::python(d.uid, d.wb.active_sheet(), at, py.code.clone()).on_sheet(&d.wb); + ed.py_cell = true; + ed.window = self.session.active_window(); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + return; + } let locale = self.ui.language.formula_locale(); let current = if sh.cell_pictures.contains_key(&at) { String::new() @@ -41,8 +63,7 @@ impl SheetApp { Some(t) => (t, true), None => (current.clone(), false), }; - let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet(), at, text, replace, from_formula_bar); - ed.code_edit = !replace && sh.cell(at).is_some_and(|c| c.py.is_some()); + let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet(), at, text, replace, from_formula_bar).on_sheet(&d.wb); ed.window = self.session.active_window(); ed.locale = locale; if replace && self.ui.autocomplete && !ed.is_formula() { @@ -97,15 +118,27 @@ impl SheetApp { } fn commit_edit_here(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { - let Some(ed) = self.editor.take() else { return true }; + // Python code goes in through `python.set`, whichever way the edit is committed (the + // formula bar's Enter button, a click on the sheet). + if self.editor.as_ref().is_some_and(|e| e.python) { + let ok = self.commit_python_edit(); + if ok && (dr != 0 || dc != 0) { + self.move_after_enter(dr, dc); + } + return ok; + } + let Some(mut ed) = self.editor.take() else { return true }; self.session.mode = gridcraft_engine::Mode::Ready; + if !self.follow_draft_sheet(&mut ed) { + return true; + } let text = match &ed.completion { // A code edit commits the code as typed, whatever a completion holds. - Some(full) if !ed.code_edit && full.to_lowercase().starts_with(&ed.text.to_lowercase()) => full.clone(), + Some(full) if !ed.python && full.to_lowercase().starts_with(&ed.text.to_lowercase()) => full.clone(), _ => ed.text.clone(), }; // F2 then Enter (or a file command) on an untouched draft: nothing to write. - if (ed.code_edit || (!fill_selection && !array)) && ed.original.as_ref() == Some(&text) { + if !fill_selection && !array && ed.original.as_ref() == Some(&text) { if dr != 0 || dc != 0 { self.move_after_enter(dr, dc); } @@ -118,11 +151,6 @@ impl SheetApp { self.message = Some(("GridCraft".into(), m)); return true; } - // All the code deleted: the cell is cleared through cell.set, as an empty entry clears any - // other cell. Code of spaces is still code. - if ed.code_edit && !text.is_empty() { - return self.commit_code_edit(ed, text, dr, dc); - } let text = match self.session.active().map(|d| formula_locale::canonical(&text, ed.locale, &d.wb, ed.sheet, ed.cell)) { Some(Ok(text)) => text, Some(Err(error)) => { @@ -187,29 +215,55 @@ impl SheetApp { } } - /// Commits a changed edit of a Python cell's code through `python.set`, which keeps the cell's - /// return type; `cell.set` would replace the Python cell with an ordinary one holding the code - /// as text. When the command refuses the code, its message shows and the edit stays open. - fn commit_code_edit(&mut self, ed: editor::EditState, code: String, dr: i64, dc: i64) -> bool { - // Point mode can leave another sheet active; the edit belongs to the sheet it started on. - let r = if self.session.active().is_some_and(|d| d.wb.active_sheet() != ed.sheet) { - self.session.run("sheet.activate", json!({"sheet": ed.sheet})) - } else { - Ok(Json::Null) + /// After a command, keeps the open draft on its own sheet: a sheet inserted, deleted or moved + /// before it (an undo or a macro too) changes which sheet sits at its index. The draft is then + /// drawn, pointed from and committed on its sheet, and the point-mode anchor, held by sheet + /// index, is let go. A draft is dropped at once with a message when its sheet is gone, or when + /// it holds Python code that sheet protection now hides (a draft in Python mode, or one that + /// began on a Python cell): neither editor shows hidden code, as the Python editor window + /// closes then too. + pub(crate) fn follow_open_draft(&mut self) { + let Some(ed) = self.editor.as_ref() else { return }; + let Some(d) = self.session.documents().iter().find(|d| d.uid == ed.doc) else { return }; + let order = editor::sheet_order(&d.wb); + let moved = ed.sheet_uid != 0 && order != ed.sheet_order; + let found = if moved { ed.sheet_in(&d.wb) } else { Some(ed.sheet) }; + let hidden = found.and_then(|i| d.wb.sheet(i)).is_some_and(|sh| { + (ed.python || ed.py_cell || sh.cell(ed.cell).is_some_and(|c| c.py.is_some())) + && gridcraft_engine::display::formula_hidden(&d.wb, sh, ed.cell) + }); + let why = match found { + None => Some(DRAFT_SHEET_GONE), + Some(_) if hidden => Some(DRAFT_CODE_HIDDEN), + Some(_) => None, }; - let r = r.and_then(|_| self.session.run("python.set", json!({"sheet": ed.sheet, "cell": ed.cell.a1(), "code": code}))); - match r { - Ok(_) => { - if dr != 0 || dc != 0 { - self.move_after_enter(dr, dc); - } - self.after_engine(); + if let Some(why) = why { + self.editor = None; + self.session.mode = gridcraft_engine::Mode::Ready; + self.message = Some(("GridCraft".into(), why.into())); + return; + } + if let (true, Some(sheet), Some(ed)) = (moved, found, self.editor.as_mut()) { + ed.sheet = sheet; + ed.sheet_order = order; + ed.point_anchor = None; + } + } + + /// Points a draft being committed at its sheet, found by identity: commands (a sheet inserted + /// or deleted before it, a move, an undo, a macro) change which sheet sits at its index while + /// it is open. A draft whose sheet is gone is dropped with a message, as one whose cell + /// protection locks is, rather than being entered on whichever sheet took its place; `false` + /// then. + fn follow_draft_sheet(&mut self, ed: &mut editor::EditState) -> bool { + let Some(d) = self.session.active() else { return true }; + match ed.sheet_in(&d.wb) { + Some(sheet) => { + ed.sheet = sheet; true } - Err(e) => { - self.message = Some(("GridCraft".into(), clean_error(&e))); - self.editor = Some(ed); - self.session.mode = gridcraft_engine::Mode::Edit; + None => { + self.message = Some(("GridCraft".into(), DRAFT_SHEET_GONE.into())); false } } @@ -229,6 +283,97 @@ impl SheetApp { self.session.mode = gridcraft_engine::Mode::Ready; } + /// Starts editing the active cell in Python mode, in the formula bar: its code, or no code + /// yet for a cell that isn't a Python cell. A cell inside a spill isn't edited, as with the + /// formula bar's other entry paths. + pub fn begin_python_edit(&mut self) { + if formula_bar::active_spill_anchor(self).is_some() { + return; + } + if let Err(e) = self.commit_text_box_edit() { + self.message = Some(("Text Box".into(), clean_error(&e))); + return; + } + let Some(d) = self.session.active() else { return }; + let Some(sh) = d.wb.active() else { return }; + let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active); + // A locked cell on a protected sheet is refused before a draft exists, as `begin_edit` + // refuses it (#2945). + if let Some(m) = self.session.locked_cell_message(d.wb.active_sheet(), at) { + self.message = Some(("GridCraft".into(), m)); + return; + } + let py = sh.cell(at).and_then(|c| c.py.as_deref()); + // A Python cell's code hidden on a protected sheet stays out of view, and Python mode + // doesn't start on it with no code, which a commit would write over the hidden code. + if py.is_some() && gridcraft_engine::display::formula_hidden(&d.wb, sh, at) { + return; + } + let code = py.map(|py| py.code.clone()).unwrap_or_default(); + // The draft belongs to this window, as `begin_edit`'s do: switching windows commits it here. + let mut ed = editor::EditState::python(d.uid, d.wb.active_sheet(), at, code).on_sheet(&d.wb); + ed.py_cell = py.is_some(); + ed.window = self.session.active_window(); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + } + + /// Ctrl+Alt+Shift+P (and the PY badge): Python mode on or off. An edit in progress keeps its + /// text; with no edit, Python mode starts on the active cell. + pub fn toggle_python_mode(&mut self) { + match self.editor.as_mut() { + Some(ed) if ed.python => ed.leave_python(), + Some(ed) => ed.enter_python(), + None => return self.begin_python_edit(), + } + self.session.mode = gridcraft_engine::Mode::Edit; + } + + /// Commits a Python mode edit through `python.set`, on the sheet the edit started on; with no + /// code left, a Python cell is cleared through `cell.set`. When the command refuses (code + /// longer than a cell holds), its message shows and the edit stays open with the code as + /// typed. + pub fn commit_python_edit(&mut self) -> bool { + let Some(mut ed) = self.editor.take() else { return true }; + self.session.mode = gridcraft_engine::Mode::Ready; + if !self.follow_draft_sheet(&mut ed) { + return true; + } + // The code of a Python cell committed unchanged: nothing to write (no undo step, the + // workbook stays clean), as with F2 then Enter on any other cell. + let code = gridcraft_engine::model::python::normalize_code(&ed.text); + let old = self.session.active().and_then(|d| d.wb.sheet(ed.sheet)).and_then(|sh| sh.cell(ed.cell)).and_then(|c| c.py.as_deref()); + let python_cell = old.is_some(); + if old.is_some_and(|py| py.code == code) { + return true; + } + // A draft on a cell sheet protection now locks can never be entered: it is dropped with + // the warning, as `commit_edit_here` drops an ordinary one (#2945). + if let Some(m) = self.session.locked_cell_message(ed.sheet, ed.cell) { + self.message = Some(("GridCraft".into(), m)); + return true; + } + // No code at all: a Python cell is cleared through cell.set, as an empty entry clears any + // other cell (code of spaces is still code). On another cell there is nothing to write. + let r = match (ed.text.is_empty(), python_cell) { + (true, false) => return true, + (true, true) => self.session.run("cell.set", json!({"sheet": ed.sheet, "cell": ed.cell.a1(), "input": ""})), + (false, _) => self.session.run("python.set", json!({"sheet": ed.sheet, "cell": ed.cell.a1(), "code": ed.text})), + }; + match r { + Ok(_) => { + self.after_engine(); + true + } + Err(e) => { + self.message = Some(("GridCraft".into(), clean_error(&e))); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + false + } + } + } + /// Enter/Tab move inside a multi-cell selection, otherwise to the next cell. pub fn move_after_enter(&mut self, dr: i64, dc: i64) { let multi = self.session.active().is_some_and(|d| !d.selection.is_single_cell()); diff --git a/crates/ui-egui/src/code_editor.rs b/crates/ui-egui/src/code_editor.rs index ee65b7ec8..f96e3b575 100644 --- a/crates/ui-egui/src/code_editor.rs +++ b/crates/ui-egui/src/code_editor.rs @@ -371,6 +371,8 @@ pub struct Applied { /// The keyboard events held back for the next pass, in order: the editor's first key after /// an event the text field acts on, and every keyboard event after that key. pub held: Vec, + /// The caller's key the pass stopped at (see [`apply_in_order_until`]). + pub stop: Option, } /// Applies the pass's input to `text` in the order it came. A pass can carry several events @@ -387,6 +389,22 @@ pub struct Applied { /// follows each one, not the key. `sel` is the selection as char indices (anchor, caret), /// updated. pub fn apply_in_order(events: &mut Vec, text: &mut String, sel: &mut (usize, usize), lang: Lang) -> Applied { + apply_in_order_until(events, text, sel, lang, &|_| false) +} + +/// [`apply_in_order`], stopping at the first event `stop` picks: a key the caller acts on itself +/// (commit, cancel, a mode switch). It counts as one of the editor's keys for the order: the +/// typing before it is applied first, and after an event the text field acts on it is held back +/// for the next pass with the rest. Once the pass stops (`Applied::stop`), the keyboard events +/// after the stop key are dropped: the caller acts on the text as it was when the key was +/// pressed, and what followed it was typed after it. +pub fn apply_in_order_until( + events: &mut Vec, + text: &mut String, + sel: &mut (usize, usize), + lang: Lang, + stop: &dyn Fn(&egui::Event) -> bool, +) -> Applied { let mut out = Applied::default(); // An event the field acts on has gone by: the editor's keys after it wait for the next pass. let mut field_seen = false; @@ -394,10 +412,25 @@ pub fn apply_in_order(events: &mut Vec, text: &mut String, sel: &mu let mut holding = false; let mut keep = Vec::with_capacity(events.len()); for ev in std::mem::take(events) { + if out.stop.is_some() { + if !is_keyboard(&ev) { + keep.push(ev); + } + continue; + } if holding && is_keyboard(&ev) { out.held.push(ev); continue; } + if is_keyboard(&ev) && stop(&ev) { + if field_seen { + holding = true; + out.held.push(ev); + } else { + out.stop = Some(ev); + } + continue; + } match classify(&ev) { Class::Edit(_) if field_seen => { holding = true; @@ -502,18 +535,88 @@ pub fn indent(text: &mut String, sel: (usize, usize), dedent: bool) -> Edit { /// caret. pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max_lines: usize) -> Response { let t = Tokens::get(ui.ctx()); - let font = font(); - let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font)); + let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font())); let max_lines = max_lines.clamp(1, 200); + let mut response = None; + egui::Frame::new().fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).corner_radius(4.0).inner_margin(4.0).show(ui, |ui| { + let options = FieldOptions { max_height: row_h * max_lines as f32, ..FieldOptions::new(lang) }; + response = Some(code_field(ui, id, text, &options).response); + }); + response.unwrap_or_else(|| ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover())) +} + +/// How a [`code_field`] is laid out, and the keys its caller takes. +pub struct FieldOptions<'a> { + pub lang: Lang, + /// The field's width (`f32::INFINITY`: all there is). + pub width: f32, + /// The height shown before it scrolls; the scroll area follows the caret. + pub max_height: f32, + /// Whether it takes input. A dialog or message box over the field has the keyboard. + pub interactive: bool, + /// Keys the caller acts on itself, in input order (see [`apply_in_order_until`]). + pub stop: &'a dyn Fn(&egui::Event) -> bool, + /// Esc stays with the field (for a caller that stops at it) instead of releasing the focus. + pub keep_escape: bool, + /// Puts the caret here (a char index) before the pass's input: the caller changed the text. + pub caret: Option, + /// The field's name for screen readers. + pub name: &'a str, +} + +impl FieldOptions<'_> { + pub fn new(lang: Lang) -> FieldOptions<'static> { + FieldOptions { + lang, + width: f32::INFINITY, + max_height: 100.0, + interactive: true, + stop: &|_| false, + keep_escape: false, + caret: None, + // The editor is a generic code field, so the name is not part of the UI's translated + // strings unless the caller gives one. + name: "Code Editor", + } + } +} + +/// What a [`code_field`] laid out and did. +pub struct FieldOutput { + pub response: Response, + /// The caller's key the pass stopped at, if any. + pub stop: Option, + /// Laid-out rows of the text. + pub lines: usize, + /// The caret on screen, while the field has the focus. + pub caret: Option, + /// The caret and the other end of the selection (char indices). + pub selection: (usize, usize), +} + +/// The code editor's text field without a frame: monospace text with syntax colours, Enter that +/// keeps the indentation, Tab and Shift+Tab, applied in input order (see [`apply_in_order`]). +pub fn code_field(ui: &mut Ui, id: egui::Id, text: &mut String, options: &FieldOptions<'_>) -> FieldOutput { + let t = Tokens::get(ui.ctx()); + let font = font(); + let lang = options.lang; // The input the editor handles itself (Enter with indentation, Tab, Shift+Tab), applied in // input order with the typing around it, before the text field sees the rest; what had to // wait for the field in the previous pass comes first. - let focused = ui.memory(|m| m.has_focus(id)); + let focused = options.interactive && ui.memory(|m| m.has_focus(id)); let held = take_held(ui, id, focused); let mut edited = None; + let mut stop = None; + let count = text.chars().count(); + if let Some(caret) = options.caret { + let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + let caret = caret.min(count); + state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(caret)))); + state.store(ui.ctx(), id); + edited = Some(Edit { anchor: caret, caret }); + } if focused { let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); - let count = text.chars().count(); let mut sel = state.cursor.char_range().map(|r| (r.secondary.index.into(), r.primary.index.into())).unwrap_or((count, count)); let applied = ui.input_mut(|i| { if !held.is_empty() { @@ -521,7 +624,7 @@ pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max events.append(&mut i.events); i.events = events; } - apply_in_order(&mut i.events, text, &mut sel, lang) + apply_in_order_until(&mut i.events, text, &mut sel, lang, options.stop) }); if applied.edited { state.cursor.set_char_range(Some(CCursorRange::two(CCursor::new(sel.0), CCursor::new(sel.1)))); @@ -531,6 +634,7 @@ pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max if !applied.held.is_empty() { hold(ui, id, applied.held); } + stop = applied.stop; } let syntax = t.syntax; let plain = t.text; @@ -538,35 +642,40 @@ pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max let job = layout_job(buf.as_str(), lang, &self::font(), plain, &syntax, wrap_width); ui.fonts_mut(|f| f.layout_job(job)) }; - let frame_h = row_h * max_lines as f32 + 8.0; - let mut response = None; - egui::Frame::new().fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).corner_radius(4.0).inner_margin(4.0).show(ui, |ui| { - egui::ScrollArea::vertical().id_salt(id.with("scroll")).max_height(frame_h - 8.0).auto_shrink([false, true]).show(ui, |ui| { + let filter = egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: options.keep_escape }; + let scrolled = + egui::ScrollArea::vertical().id_salt(id.with("scroll")).max_height(options.max_height.max(1.0)).auto_shrink([false, true]).show(ui, |ui| { let out = TextEdit::multiline(text) .id(id) .font(font.clone()) .code_editor() .frame(egui::Frame::NONE) - .desired_width(f32::INFINITY) + .desired_width(options.width) .desired_rows(1) - .lock_focus(true) + .interactive(options.interactive) + .event_filter(filter) .layouter(&mut layouter) .show(ui); if let Some(e) = edited { let caret = out.galley.pos_from_cursor(CCursor::new(e.caret)).translate(out.galley_pos.to_vec2()); ui.scroll_to_rect(caret, None); } - // The field has no visible label; name it for screen readers and the strict - // kittest accessibility check. The editor is a generic code field, so the name is - // not part of the UI's translated strings. - let mut r = out.response.response.accessible_name("Code Editor"); - if edited.is_some() { - r.mark_changed(); - } - response = Some(r); + out }); - }); - response.unwrap_or_else(|| ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover())) + let out = scrolled.inner; + // The field has no visible label; name it for screen readers and the strict kittest + // accessibility check. + let mut response = out.response.response.accessible_name(options.name); + if edited.is_some() { + response.mark_changed(); + } + let selection = out.cursor_range.map(|r| (r.primary.index.into(), r.secondary.index.into())).unwrap_or((count, count)); + let caret = out + .cursor_range + .map(|r| out.galley.pos_from_cursor(r.primary).translate(out.galley_pos.to_vec2())) + .filter(|_| response.has_focus()) + .map(|r| r.intersect(scrolled.inner_rect.expand(1.0))); + FieldOutput { response, stop, lines: out.galley.rows.len().max(1), caret, selection } } #[cfg(test)] diff --git a/crates/ui-egui/src/code_editor/tests.rs b/crates/ui-egui/src/code_editor/tests.rs index ba3c08efa..0a1fc3bb1 100644 --- a/crates/ui-egui/src/code_editor/tests.rs +++ b/crates/ui-egui/src/code_editor/tests.rs @@ -278,6 +278,41 @@ fn tab_and_enter_under_modifiers_never_reach_the_field() { assert!(out.held.is_empty()); } +#[test] +fn a_stop_key_ends_the_pass_in_input_order() { + let stop = |ev: &egui::Event| matches!(ev, egui::Event::Key { key: Key::Enter, pressed: true, modifiers, .. } if modifiers.command); + // The typing before it is applied; the keys after it are dropped, other input stays. + let mut events = vec![ + egui::Event::Text("a".into()), + key(Key::Enter, Modifiers::COMMAND), + egui::Event::Text("b".into()), + egui::Event::PointerMoved(egui::Pos2::ZERO), + key(Key::Tab, Modifiers::NONE), + ]; + let (mut s, mut sel) = (String::new(), (0, 0)); + let out = apply_in_order_until(&mut events, &mut s, &mut sel, Lang::Python, &stop); + assert_eq!(s, "a"); + assert_eq!(out.stop, Some(key(Key::Enter, Modifiers::COMMAND))); + assert_eq!(events, vec![egui::Event::PointerMoved(egui::Pos2::ZERO)]); + assert!(out.held.is_empty()); + // After a key the field acts on, it waits for the next pass with the editor's keys + // before it, so the edit it ends includes them. + let mut events = vec![key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE), key(Key::Enter, Modifiers::COMMAND)]; + let out = apply_in_order_until(&mut events, &mut s, &mut sel, Lang::Python, &stop); + assert_eq!(out.stop, None); + assert_eq!(events, vec![key(Key::A, Modifiers::COMMAND)]); + assert_eq!(out.held, vec![key(Key::Tab, Modifiers::NONE), key(Key::Enter, Modifiers::COMMAND)]); + // Next pass: the Tab, then the stop. + let mut events = out.held; + let out = apply_in_order_until(&mut events, &mut s, &mut sel, Lang::Python, &stop); + assert_eq!(s, "a "); + assert_eq!(out.stop, Some(key(Key::Enter, Modifiers::COMMAND))); + // Without a stop the editor behaves as before: the caller's key is the field's. + let mut events = vec![key(Key::Enter, Modifiers::COMMAND)]; + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert_eq!((out.stop, events.len()), (None, 1)); +} + #[test] fn events_after_a_held_key_wait_with_it_and_pointer_events_do_not() { let mut events = vec![ diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index af5eda156..d7919e60a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -145,7 +145,7 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json { "window": [r.width(), r.height()], "grid": app.grid.cells_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])), "scroll": [v.scroll.x, v.scroll.y], - "editor": app.editor.as_ref().map(|e| json!({"cell": e.cell.a1(), "text": e.text, "formulaBar": e.from_formula_bar, "point": e.can_point()})), + "editor": app.editor.as_ref().map(|e| editor_json(app, e)), "dialog": app.dialog.as_ref().map(|d| json!({"name": d.name, "title": d.title, "values": d.values, "tab": d.tab})), "backstage": app.backstage.as_ref().map(|b| b.page.id()), "message": app.message.as_ref().map(|(t, m)| json!({"title": t, "text": m})), @@ -159,6 +159,22 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json { }) } +/// The open edit, for `ui.inspect`. The draft is left out (`text` is null, `hidden` true) while +/// its cell's code is hidden on a protected sheet, checked now: a protection command earlier in +/// the same batch runs before the next frame. That holds for a draft in Python mode, and for any +/// draft of a Python cell, since leaving Python mode keeps the code as an ordinary draft. The +/// sheet is the draft's own, found by identity: a sheet inserted, deleted or moved in the same +/// batch puts another at its index. With its sheet gone, such a draft is left out too. +fn editor_json(app: &SheetApp, e: &crate::editor::EditState) -> Json { + let code = |sh: &gridcraft_engine::model::Sheet| e.python || sh.cell(e.cell).is_some_and(|c| c.py.is_some()); + let hidden = app.session.documents().iter().find(|d| d.uid == e.doc).is_some_and(|d| match e.sheet_in(&d.wb).and_then(|i| d.wb.sheet(i)) { + Some(sh) => code(sh) && gridcraft_engine::display::formula_hidden(&d.wb, sh, e.cell), + None => e.python || e.py_cell, + }); + let text = if hidden { Json::Null } else { json!(e.text) }; + json!({"cell": e.cell.a1(), "text": text, "hidden": hidden, "formulaBar": e.from_formula_bar, "point": e.can_point(), "python": e.python}) +} + /// Keeps the commands whose id, label or ribbon path contains `search` (case-insensitive); `None` keeps all. fn filter_commands(commands: Vec, search: Option<&str>) -> Vec { let Some(q) = search.map(str::to_ascii_lowercase) else { return commands }; @@ -192,6 +208,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("app.checkForUpdates", "Check for Updates…", ""), ("view.collapseRibbon", "Collapse Ribbon", ""), ("view.expandFormulaBar", "Expand Formula Bar", "{on?: bool} (toggles without it) → {on}"), + ("ui.pythonMode", "Python Mode", "{on?: bool} (toggles without it) → {on}"), ("view.darkMode", "Dark Mode", ""), ("view.theme", "Display Theme", ""), ("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"), diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index a680279e5..1183535e8 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -977,3 +977,234 @@ fn synthetic_text_key_and_move_producers_honour_the_queue_budget() { assert_eq!(reply["ok"], false, "{reply}"); assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), MAX_QUEUED_SYNTHETIC_EVENTS); } + +#[test] +fn python_ui_commands_are_listed() { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, crate::Services::default()); + let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search": "python"})) else { + panic!("command discovery must return immediately"); + }; + let commands = reply["result"].as_array().cloned().unwrap_or_default(); + let listed = [("ui.pythonMode", "{on?: bool} (toggles without it) → {on}")]; + for (id, docs) in listed { + let command = commands.iter().find(|c| c["id"] == id).unwrap_or_else(|| panic!("engine.commands lists {id}")); + assert_eq!(command["ui"], true, "{id}"); + assert_eq!(command["params"], docs, "{id}"); + } +} + +/// Runs `command` through the control channel, as one line of a batch: no frame runs after it. +fn execute(app: &mut SheetApp, ctx: &egui::Context, command: &str, params: Json) { + let Outcome::Done(reply) = handle(app, ctx, "engine.execute", &json!({"command": command, "params": params})) else { + panic!("{command} answers at once"); + }; + assert_eq!(reply["ok"], true, "{command}: {reply}"); +} + +fn inspected(app: &mut SheetApp, ctx: &egui::Context) -> Json { + let Outcome::Done(reply) = handle(app, ctx, "ui.inspect", &json!({})) else { panic!("ui.inspect answers at once") }; + reply["result"].clone() +} + +#[test] +fn a_python_draft_on_code_hidden_in_the_same_batch_shows_no_code() { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + session.execute("python.set", json!({"cell": "B2", "code": "secret = 41\nsecret + 1"})).unwrap(); + session.execute("home.formatCells", json!({"range": "B2", "style": {"protection": {"hidden": true}}})).unwrap(); + session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + let mut app = SheetApp::new(session, crate::Services::default()); + let ctx = egui::Context::default(); + execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true})); + let before = inspected(&mut app, &ctx); + assert_eq!(before["editor"]["text"], "secret = 41\nsecret + 1", "unprotected, the draft shows its code"); + // Protected on the session itself, so no app command drops the draft first: `ui.inspect` + // still leaves the code out, checked when it is called. + app.session.execute("review.protectSheet", json!({})).unwrap(); + let after = inspected(&mut app, &ctx); + assert!(app.editor.as_ref().is_some_and(|e| e.python), "the draft is still open"); + assert!(!after.to_string().contains("secret"), "{after}"); + assert_eq!(after["editor"]["text"], Json::Null); + assert_eq!(after["editor"]["hidden"], true); + // Out of Python mode the draft still holds the hidden cell's code. + if let Some(e) = app.editor.as_mut() { + e.leave_python(); + } + assert!(app.editor.as_ref().is_some_and(|e| !e.python && e.text.contains("secret")), "an ordinary draft holds the code"); + let left = inspected(&mut app, &ctx); + assert!(!left.to_string().contains("secret"), "{left}"); + assert_eq!(left["editor"]["hidden"], true); +} + +/// Answers one control request that must answer at once with `ok`. +fn call(app: &mut SheetApp, ctx: &egui::Context, method: &str, params: Json) { + let Outcome::Done(reply) = handle(app, ctx, method, ¶ms) else { panic!("{method} answers at once") }; + assert_eq!(reply["ok"], true, "{method}: {reply}"); +} + +/// A Python draft of hidden code at B2 on Sheet1, protected after the draft opened, with an +/// unprotected sheet `Other` after it when `other` is set. +fn hidden_python_draft(other: bool) -> (SheetApp, egui::Context) { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + session.execute("python.set", json!({"cell": "B2", "code": "secret = 41\nsecret + 1"})).unwrap(); + session.execute("home.formatCells", json!({"range": "B2", "style": {"protection": {"hidden": true}}})).unwrap(); + if other { + session.execute("home.insertSheet", json!({"name": "Other", "before": 1})).unwrap(); + session.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + } + session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + let mut app = SheetApp::new(session, crate::Services::default()); + let ctx = egui::Context::default(); + execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true})); + // On the session itself, so the draft stays open for `ui.inspect` to be checked against. + app.session.execute("review.protectSheet", json!({})).unwrap(); + assert_eq!(inspected(&mut app, &ctx)["editor"]["hidden"], true, "hidden on its own sheet"); + (app, ctx) +} + +#[test] +fn a_python_draft_shows_no_code_when_another_sheet_takes_its_index_in_the_same_batch() { + let (mut app, ctx) = hidden_python_draft(false); + // An unprotected sheet now sits at the draft's old index, in the same batch. + app.session.execute("home.insertSheet", json!({"name": "New", "before": 0})).unwrap(); + let after = inspected(&mut app, &ctx); + assert!(!after.to_string().contains("secret"), "{after}"); + assert_eq!(after["editor"]["text"], Json::Null); + assert_eq!(after["editor"]["hidden"], true); +} + +#[test] +fn a_python_draft_shows_no_code_once_its_sheet_is_deleted_in_the_same_batch() { + let (mut app, ctx) = hidden_python_draft(true); + // The draft's own sheet goes, and the unprotected `Other` takes its index. + app.session.execute("home.deleteSheet", json!({"sheet": "Sheet1"})).unwrap(); + assert_eq!(app.session.active().map(|d| d.wb.sheets.len()), Some(1)); + let after = inspected(&mut app, &ctx); + assert!(!after.to_string().contains("secret"), "{after}"); + assert_eq!(after["editor"]["hidden"], true); + // The next app command (an undo that brings the protected sheet back) drops the draft, since + // its code is hidden there; deleting a draft's sheet is covered by + // `a_draft_whose_sheet_is_deleted_is_dropped_with_a_message`. + execute(&mut app, &ctx, "edit.undo", json!({})); + assert!(app.editor.is_none(), "the draft is dropped"); + assert_eq!(app.message.as_ref().map(|m| m.1.as_str()), Some(crate::app::DRAFT_CODE_HIDDEN)); + assert_eq!(inspected(&mut app, &ctx)["editor"], Json::Null); +} + +/// Sheet `name`'s A1: its input text and whether it is a Python cell. +fn a1(app: &SheetApp, name: &str) -> Option<(String, bool)> { + let d = app.session.active()?; + let sh = d.wb.sheet(d.wb.sheet_index(name)?)?; + sh.cell(gridcraft_engine::core::CellRef::new(0, 0)).map(|c| (c.input_text(), c.py.is_some())) +} + +#[test] +fn a_draft_is_committed_on_its_own_sheet_after_a_sheet_is_inserted_before_it() { + // An ordinary draft and a Python draft on Sheet1!A1, then a sheet inserted before Sheet1. + for python in [false, true] { + let mut app = test_app(); + let ctx = egui::Context::default(); + if python { + execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true})); + call(&mut app, &ctx, "ui.edit.text", json!({"text": "1 + 1"})); + } else { + call(&mut app, &ctx, "ui.edit.begin", json!({"text": "42"})); + } + execute(&mut app, &ctx, "home.insertSheet", json!({"name": "New", "before": 0})); + call(&mut app, &ctx, "ui.edit.commit", json!({"move": "none"})); + assert!(app.editor.is_none(), "python {python}"); + assert_eq!(a1(&app, "New"), None, "python {python}: nothing lands on the inserted sheet"); + let got = a1(&app, "Sheet1"); + if python { + assert_eq!(got.map(|g| g.1), Some(true), "the code lands on the draft's own sheet"); + } else { + assert_eq!(got, Some(("42".to_string(), false)), "the entry lands on the draft's own sheet"); + } + } +} + +#[test] +fn a_draft_whose_sheet_is_deleted_is_dropped_with_a_message() { + for python in [false, true] { + let mut app = test_app(); + let ctx = egui::Context::default(); + execute(&mut app, &ctx, "home.insertSheet", json!({"name": "Other", "before": 1})); + execute(&mut app, &ctx, "sheet.activate", json!({"sheet": 0})); + if python { + execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true})); + call(&mut app, &ctx, "ui.edit.text", json!({"text": "1 + 1"})); + } else { + call(&mut app, &ctx, "ui.edit.begin", json!({"text": "42"})); + } + execute(&mut app, &ctx, "home.deleteSheet", json!({"sheet": "Sheet1"})); + // Dropped once the command is done, not left drawn on the sheet in its place. + assert!(app.editor.is_none(), "python {python}: the draft is dropped"); + assert_eq!(inspected(&mut app, &ctx)["editor"], Json::Null, "python {python}"); + assert_eq!(a1(&app, "Other"), None, "python {python}: nothing lands on the sheet in its place"); + assert_eq!(app.message.as_ref().map(|m| m.1.as_str()), Some(crate::app::DRAFT_SHEET_GONE), "python {python}"); + } +} + +#[test] +fn an_open_draft_follows_its_sheet_when_a_sheet_is_inserted_before_it() { + for python in [false, true] { + let mut app = test_app(); + let ctx = egui::Context::default(); + if python { + execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true})); + } else { + call(&mut app, &ctx, "ui.edit.begin", json!({"text": "=SUM("})); + } + if let Some(ed) = app.editor.as_mut() { + ed.point_anchor = Some((0, gridcraft_engine::core::CellRef::parse("C3").unwrap())); + } + execute(&mut app, &ctx, "home.insertSheet", json!({"name": "Before", "before": 0})); + let ed = app.editor.as_ref().expect("the draft stays open"); + assert_eq!(ed.sheet, 1, "python {python}: the draft is on Sheet1, now at index 1, so it is drawn there"); + assert_eq!(ed.point_anchor, None, "python {python}: the anchor's sheet index no longer names its sheet"); + // A rename changes no index: nothing is let go. + if let Some(ed) = app.editor.as_mut() { + ed.point_anchor = Some((1, gridcraft_engine::core::CellRef::parse("C3").unwrap())); + } + execute(&mut app, &ctx, "sheet.rename", json!({"sheet": "Before", "name": "First"})); + assert!(app.editor.as_ref().is_some_and(|e| e.sheet == 1 && e.point_anchor.is_some()), "python {python}"); + } +} + +#[test] +fn a_draft_of_python_code_that_protection_hides_is_dropped_with_a_message() { + // In Python mode, and after leaving it (an ordinary draft of a Python cell's code). + for leave in [false, true] { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + session.execute("python.set", json!({"cell": "B2", "code": "secret = 41"})).unwrap(); + session.execute("cell.set", json!({"cell": "C2", "input": "=1+1"})).unwrap(); + session.execute("home.formatCells", json!({"range": "B2:C2", "style": {"protection": {"hidden": true, "locked": false}}})).unwrap(); + session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + let mut app = SheetApp::new(session, crate::Services::default()); + let ctx = egui::Context::default(); + execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true})); + if leave { + execute(&mut app, &ctx, "ui.pythonMode", json!({"on": false})); + assert!(app.editor.as_ref().is_some_and(|e| !e.python && e.text.contains("secret")), "an ordinary draft of the code"); + } + execute(&mut app, &ctx, "review.protectSheet", json!({})); + assert!(app.editor.is_none(), "leave {leave}: neither editor keeps hidden code on screen"); + assert_eq!(app.message.as_ref().map(|m| m.1.as_str()), Some(crate::app::DRAFT_CODE_HIDDEN), "leave {leave}"); + assert!(!inspected(&mut app, &ctx).to_string().contains("secret")); + } + // An ordinary formula draft on a hidden cell stays open, as on main. + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + session.execute("cell.set", json!({"cell": "C2", "input": "=1+1"})).unwrap(); + session.execute("home.formatCells", json!({"range": "C2", "style": {"protection": {"hidden": true, "locked": false}}})).unwrap(); + session.execute("selection.set", json!({"cell": "C2"})).unwrap(); + let mut app = SheetApp::new(session, crate::Services::default()); + let ctx = egui::Context::default(); + call(&mut app, &ctx, "ui.edit.begin", json!({"text": "=2+2"})); + execute(&mut app, &ctx, "review.protectSheet", json!({})); + assert!(app.editor.as_ref().is_some_and(|e| e.text == "=2+2"), "a formula draft is unchanged"); +} diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index 8fe3bc7d2..d3cab75ea 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -13,7 +13,19 @@ pub struct EditState { /// The window it was typed in: switching to another window, even on the same workbook, /// commits it there first (`None` until the app sets it). pub window: Option, + /// The sheet's index when the draft was last resolved; [`EditState::sheet_in`] finds it again + /// by `sheet_uid` after sheets are inserted, deleted or moved. pub sheet: usize, + /// The sheet's identity while the app runs (`model::SheetUid`), recorded when the draft is + /// created ([`EditState::on_sheet`]); 0 (never given out) when none was. + pub sheet_uid: u64, + /// The draft began on a Python cell, with its code as the text. Kept so the code stays out of + /// view once the cell can't be looked at, its sheet gone. + pub py_cell: bool, + /// The order of the workbook's sheets ([`sheet_order`]) when the draft was last resolved: a + /// change after a command points the draft at its sheet again and lets go of + /// `point_anchor`, which holds a sheet index. + pub sheet_order: u64, /// Syntax of this edit, captured when it starts (prevents a language switch reinterpreting it). pub locale: gridcraft_engine::formula::FormulaLocale, pub cell: CellRef, @@ -47,10 +59,15 @@ pub struct EditState { /// The cell's text when an F2-style edit began. Committing it unchanged leaves the cell alone /// (no undo step, the workbook stays clean). `None` for an edit started by typing. pub original: Option, - /// The edit began on a Python cell's existing content (F2, a double click or the formula - /// bar), so the text is the cell's code: a change goes in as new code through `python.set`. - /// `false` for an edit started by typing, which replaces the cell as it does a formula. - pub code_edit: bool, + /// Python mode: the text is a Python cell's code, edited in the formula bar's code editor and + /// committed with Ctrl+Enter (Cmd+Enter on macOS) through `python.set`. + pub python: bool, +} + +/// A digest of the order of `wb`'s sheets by identity: it changes when a sheet is inserted, +/// deleted or moved, not when one is renamed or edited. +pub fn sheet_order(wb: &gridcraft_engine::model::Workbook) -> u64 { + wb.sheets.iter().fold(0xcbf2_9ce4_8422_2325_u64, |h, sh| (h ^ sh.uid.get()).wrapping_mul(0x0000_0100_0000_01b3)) } impl EditState { @@ -61,6 +78,9 @@ impl EditState { doc, window: None, sheet, + sheet_uid: 0, + py_cell: false, + sheet_order: 0, locale: Default::default(), cell, text, @@ -79,14 +99,79 @@ impl EditState { caret_to_end: true, completion: None, original, - code_edit: false, + python: false, } } + /// An edit of a cell's Python code, in the formula bar. + pub fn python(doc: u64, sheet: usize, cell: CellRef, code: String) -> EditState { + let mut ed = EditState::new(doc, sheet, cell, code, false, true); + ed.python = true; + ed + } + + /// Records the identity of the draft's sheet, from the workbook it is typed in. + pub fn on_sheet(mut self, wb: &gridcraft_engine::model::Workbook) -> EditState { + self.sheet_uid = wb.sheet(self.sheet).map_or(0, |sh| sh.uid.get()); + self.sheet_order = sheet_order(wb); + self + } + + /// The draft's sheet's index now, found by its identity, since commands insert, delete and + /// move sheets under an open draft; `None` once the sheet is gone (or none was recorded). + pub fn sheet_in(&self, wb: &gridcraft_engine::model::Workbook) -> Option { + if self.sheet_uid == 0 { + return None; + } + if wb.sheet(self.sheet).is_some_and(|sh| sh.uid.get() == self.sheet_uid) { + return Some(self.sheet); + } + wb.sheets.iter().position(|sh| sh.uid.get() == self.sheet_uid) + } + + /// Whether the text is the `=PY` that Tab turns into Python mode, as in Excel (`=PY(` too, + /// in any letter case). + pub fn is_py_trigger(&self) -> bool { + let t = self.text.trim(); + !self.python && (t.eq_ignore_ascii_case("=PY") || t.eq_ignore_ascii_case("=PY(")) + } + + /// Switches the edit to Python mode, in the formula bar. The `=PY` trigger becomes empty code; + /// other text is kept as the code, so the shortcut pressed mid-edit loses nothing. + pub fn enter_python(&mut self) { + if self.is_py_trigger() { + self.text.clear(); + } + self.python = true; + self.from_formula_bar = true; + self.enter_mode = false; + self.point = None; + self.point_cell = None; + self.point_anchor = None; + self.autocomplete.clear(); + self.completion = None; + self.caret = self.text.chars().count(); + self.anchor = self.caret; + self.caret_to_end = false; + self.request_focus = true; + self.sync_caret = true; + } + + /// Leaves Python mode: the edit goes on as an ordinary entry of the same text. + pub fn leave_python(&mut self) { + self.python = false; + self.caret = self.text.chars().count(); + self.anchor = self.caret; + self.request_focus = true; + self.sync_caret = true; + } + + /// Whether the text is a formula (never in Python mode: there it is code). pub fn is_formula(&self) -> bool { - self.text.starts_with('=') - || (self.text.starts_with('+') && self.text.len() > 1) - || (self.text.starts_with('-') && self.text.len() > 1 && !self.text[1..].starts_with(|c: char| c.is_ascii_digit())) + !self.python + && (self.text.starts_with('=') + || (self.text.starts_with('+') && self.text.len() > 1) + || (self.text.starts_with('-') && self.text.len() > 1 && !self.text[1..].starts_with(|c: char| c.is_ascii_digit()))) } fn byte_caret(&self) -> usize { @@ -162,7 +247,7 @@ impl EditState { /// Updates function autocomplete for the word before the caret. pub fn update_autocomplete(&mut self, names: &[String]) { self.autocomplete.clear(); - if !self.is_formula() { + if self.python || !self.is_formula() { return; } let before = self.text.get(..self.byte_caret()).unwrap_or(""); @@ -236,7 +321,7 @@ impl EditState { /// The function call the caret is inside and the argument index (for the hint tooltip). pub fn current_function(&self) -> Option<(String, usize)> { - if !self.is_formula() { + if self.python || !self.is_formula() { return None; } let before = self.text.get(..self.byte_caret())?; diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 1f0bf11b3..3f2d08a60 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -1,9 +1,11 @@ -//! The formula bar (Name Box, ✕ ✓ fx, formula field) and the shared cell editor widget. +//! The formula bar (Name Box, ✕ ✓ fx, the PY badge, formula field) and the shared cell editor +//! widget. In Python mode the field is the code editor. use egui::{FontId, Key, Rect, Sense, Stroke, StrokeKind, TextEdit, pos2, vec2}; use serde_json::json; use crate::SheetApp; +use crate::code_editor::{self, FieldOptions, Lang}; use crate::editor::formula_refs; use crate::icons::{self, Icon}; use crate::theme::{self, Tokens}; @@ -100,6 +102,9 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { app.open_dialog("insertFunction", json!({})); } layout.buttons = cancel.rect.union(fx.rect); + if let Some(badge) = python_badge(app, ui, &t) { + layout.buttons = layout.buttons.union(badge); + } ui.add_space(4.0); // The formula field fills the rest, as tall as the bar. let avail = ui.available_width() - 24.0; @@ -114,10 +119,16 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let info = if app.editor.is_some() { editor_widget(app, &mut child, id, font.clone(), true, inner.width(), inner.height()) } else { - // Show the active cell's content; clicking starts editing in the bar. + // Show the active cell's content (a Python cell's code in the code editor's + // colours); clicking starts editing in the bar. let text = active_input(app); let color = if read_only { t.text_disabled } else { t.text }; - let galley = child.painter().layout(text, font.clone(), color, inner.width()); + let galley = if active_python(app).is_some() && !read_only { + let job = code_editor::layout_job(&text, Lang::Python, &code_editor::font(), color, &t.syntax, inner.width()); + child.fonts_mut(|f| f.layout_job(job)) + } else { + child.painter().layout(text, font.clone(), color, inner.width()) + }; let lines = galley.rows.len(); // Each cell's view starts at its top: the offset is kept per cell, not per bar. let shown = app.session.active().map(|d| (d.uid, d.wb.active_sheet(), d.selection.active.row, d.selection.active.col)); @@ -156,6 +167,16 @@ pub(crate) fn active_spill_anchor(app: &SheetApp) -> Option Option<(usize, gridcraft_engine::core::CellRef, std::sync::Arc)> { + let d = app.session.active()?; + let sh = d.wb.active()?; + let a = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active); + let at = sh.spill_anchor(a).unwrap_or(a); + sh.cell(at).and_then(|c| c.py.clone()).map(|py| (d.wb.active_sheet(), at, py)) +} + fn active_input(app: &SheetApp) -> String { let Some(d) = app.session.active() else { return String::new() }; let Some(sh) = d.wb.active() else { return String::new() }; @@ -163,6 +184,14 @@ fn active_input(app: &SheetApp) -> String { if let Some(picture) = sh.cell_pictures.get(&a) { return if picture.alt.is_empty() { "Picture in cell".into() } else { format!("Picture: {}", picture.alt) }; } + // Python code is shown as it is: it isn't a formula to translate or a value to format. Like + // a formula, it stays out of view in a Hidden cell on a protected sheet. + if let Some((_, at, py)) = active_python(app) { + if gridcraft_engine::display::formula_hidden(&d.wb, sh, at) { + return String::new(); + } + return py.code.clone(); + } if let Some(anchor) = sh.spill_anchor(a) { if gridcraft_engine::display::formula_hidden(&d.wb, sh, anchor) { return String::new(); @@ -188,6 +217,66 @@ fn active_input(app: &SheetApp) -> String { String::new() } +/// Ctrl+Alt+Shift+P, and Cmd+Option+Shift+P on macOS: Python mode on or off. +pub fn is_python_shortcut(ev: &egui::Event) -> bool { + matches!(ev, egui::Event::Key { key: Key::P, pressed: true, modifiers: m, .. } if m.shift && m.alt && (m.ctrl || m.command || m.mac_cmd)) +} + +/// Takes Ctrl+Alt+Shift+P out of this pass's input; whether it was there. +fn take_python_shortcut(i: &mut egui::InputState) -> bool { + let found = i.events.iter().any(is_python_shortcut); + if found { + i.events.retain(|e| !is_python_shortcut(e)); + } + found +} + +/// What a key in Python mode does, besides editing the code. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PythonKey { + /// Ctrl+Enter (Cmd+Enter on macOS): put the code in the cell. + Commit, + /// Esc: cancel the edit. + Cancel, + /// Ctrl+Alt+Shift+P: leave Python mode. + Leave, +} + +fn python_key(ev: &egui::Event) -> Option { + match ev { + egui::Event::Key { key: Key::Enter, pressed: true, modifiers: m, .. } if (m.ctrl || m.command || m.mac_cmd) && !m.alt => { + Some(PythonKey::Commit) + } + egui::Event::Key { key: Key::Escape, pressed: true, .. } => Some(PythonKey::Cancel), + _ if is_python_shortcut(ev) => Some(PythonKey::Leave), + _ => None, + } +} + +/// The PY badge beside fx: shown in Python mode and on a selected Python cell. Clicking it +/// switches Python mode, as Ctrl+Alt+Shift+P does. Returns its rect when shown. +fn python_badge(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) -> Option { + let show = match &app.editor { + Some(ed) => ed.python, + None => active_python(app).is_some(), + }; + if !show { + return None; + } + let text = ui.painter().layout_no_wrap("PY".into(), theme::ui_bold(11.0), t.on_badge); + let (rect, resp) = ui.allocate_exact_size(vec2(text.size().x + 12.0, 24.0), Sense::click()); + let pill = Rect::from_center_size(rect.center(), vec2(rect.width(), 18.0)); + ui.painter().rect_filled(pill, 4.0, t.badge_py); + ui.painter().galley(pill.center() - text.size() / 2.0, text, t.on_badge); + let label = app.ui.language.tr("Python Mode").to_string(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); + let resp = resp.on_hover_text(format!("{label} (Ctrl+Alt+Shift+P)")); + if resp.clicked() { + app.toggle_python_mode(); + } + Some(rect) +} + /// Draws the Name Box and its name dropdown; Enter jumps to a reference or known name (or defines a new name) and is consumed so the grid does not move down. fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) -> Rect { let id = egui::Id::unique("gridcraft.name_box"); @@ -292,12 +381,19 @@ fn show_edit<'t>( /// The text editor used in the cell and in the formula bar (they share `app.editor`). It shows /// `max_height` of text and scrolls beyond that, keeping the caret in view. pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32, max_height: f32) -> EditorInfo { + if app.editor.as_ref().is_some_and(|e| e.python) { + // Python mode edits in the formula bar only. + if id != egui::Id::unique("gridcraft.formula_bar") { + return EditorInfo::default(); + } + return python_editor(app, ui, id, width, max_height); + } let Some(mut ed) = app.editor.take() else { return EditorInfo::default() }; let mine = ed.from_formula_bar == (id == egui::Id::unique("gridcraft.formula_bar")); // Keys the editor handles itself (before the TextEdit sees them), only where it has focus. let focused = ui.ctx().memory(|m| m.focused()); let has_focus = focused == Some(id) || (mine && (ed.request_focus || focused.is_none())); - let EditorKeyOutcome { action, arrow, cancel } = editor_shortcuts(app, ui, id, &mut ed, mine, has_focus); + let EditorKeyOutcome { action, arrow, cancel, to_python } = editor_shortcuts(app, ui, id, &mut ed, mine, has_focus); // The text widget. let refs = formula_refs(&ed.text); let t = Tokens::get(ui.ctx()); @@ -393,7 +489,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: // Python code is never completed from the column's text values. if app.ui.autocomplete && !ed.is_formula() - && !ed.code_edit + && !ed.python && !entry_shrank(chars_before, &ed.text) && ed.caret == ed.text.chars().count() && let Some(sh) = app.session.active().and_then(|d| d.wb.sheet(ed.sheet)) @@ -422,6 +518,12 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: show_editor_suggestions(ui, EditorSuggestion { app, id, resp: &resp, ed: &mut ed, galley: &output.galley, tokens: &t, mine }); let lines = output.galley.rows.len().max(1); let caret = Some(output.galley.pos_from_cursor(egui::text::CCursor::new(ed.caret)).translate(output.galley_pos.to_vec2())); + if to_python { + ed.enter_python(); + app.editor = Some(ed); + app.session.mode = gridcraft_engine::Mode::Edit; + return EditorInfo { lines, caret }; + } app.editor = Some(ed); if cancel { app.cancel_edit(); @@ -441,6 +543,8 @@ struct EditorKeyOutcome { action: Option, arrow: bool, cancel: bool, + /// Ctrl+Alt+Shift+P, or `=PY` then Tab: the edit turns into Python code. + to_python: bool, } fn editor_shortcuts( @@ -451,14 +555,17 @@ fn editor_shortcuts( mine: bool, has_focus: bool, ) -> EditorKeyOutcome { - let mut outcome = EditorKeyOutcome { action: None, arrow: false, cancel: false }; + let mut outcome = EditorKeyOutcome { action: None, arrow: false, cancel: false, to_python: false }; // A message or dialog owns Enter and Escape until it closes. if !has_focus || !mine || app.message.is_some() || app.dialog.is_some() { return outcome; } - let (action, cancel) = editor_confirm_keys(ui, ed); + // Ctrl+Alt+Shift+P turns the edit into Python code. + outcome.to_python = ui.input_mut(take_python_shortcut); + let (action, cancel, py_trigger) = editor_confirm_keys(ui, ed); outcome.action = action; outcome.cancel = cancel; + outcome.to_python |= py_trigger; let (action, arrow) = editor_arrow_keys(app, ui, ed, outcome.action); outcome.action = action; outcome.arrow = arrow; @@ -466,7 +573,9 @@ fn editor_shortcuts( outcome } -fn editor_confirm_keys(ui: &mut egui::Ui, ed: &mut crate::editor::EditState) -> (Option, bool) { +/// Enter, Tab, Esc and their variants: the move to make, whether the edit is cancelled, and +/// whether `=PY` then Tab starts Python mode. +fn editor_confirm_keys(ui: &mut egui::Ui, ed: &mut crate::editor::EditState) -> (Option, bool, bool) { let (enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, shift) = ui.input_mut(|i| { let m = i.modifiers; let alt_enter = i.consume_key(egui::Modifiers::ALT, Key::Enter) || i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::ALT, Key::Enter); @@ -485,7 +594,11 @@ fn editor_confirm_keys(ui: &mut egui::Ui, ed: &mut crate::editor::EditState) -> ed.caret_to_end = false; ed.sync_caret = true; } - let mut action = if !ed.autocomplete.is_empty() && tab { + // `=PY` then Tab starts Python mode, as in Excel. + let py_trigger = tab && !shift && ed.is_py_trigger(); + let mut action = if py_trigger { + None + } else if !ed.autocomplete.is_empty() && tab { ed.accept_autocomplete(); None } else if enter { @@ -512,7 +625,7 @@ fn editor_confirm_keys(ui: &mut egui::Ui, ed: &mut crate::editor::EditState) -> } else { true }; - (action, cancel) + (action, cancel, py_trigger) } fn editor_arrow_keys( @@ -606,6 +719,57 @@ fn editor_other_shortcuts(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, e } } +/// The formula bar in Python mode: the code editor (monospace, Python colours). Its keys are +/// applied in input order with the typing: Enter starts a new line with the indentation, Tab and +/// Shift+Tab indent, Ctrl+Enter (Cmd+Enter on macOS) commits through `python.set`, Esc cancels and +/// Ctrl+Alt+Shift+P leaves Python mode. +fn python_editor(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, width: f32, max_height: f32) -> EditorInfo { + let Some(mut ed) = app.editor.take() else { return EditorInfo::default() }; + // A message box (the code was refused) or a dialog over the edit has the keyboard; the + // editor takes it back when it closes. + let blocked = app.message.is_some() || app.dialog.is_some(); + let mut caret = None; + if blocked { + ed.request_focus = true; + } else { + // Focus left on nothing (a click on the sheet's chrome, a closed message box whose modal + // layer held it one more pass) comes back to the code. + if std::mem::take(&mut ed.request_focus) || ui.memory(|m| m.focused()).is_none() { + ui.memory_mut(|m| m.request_focus(id)); + } + if std::mem::take(&mut ed.sync_caret) { + caret = Some(ed.caret); + } + } + let name = app.ui.language.tr("Formula Bar").to_string(); + let stop = |ev: &egui::Event| python_key(ev).is_some(); + let options = FieldOptions { + width, + max_height, + interactive: !blocked, + stop: &stop, + keep_escape: true, + caret, + name: &name, + ..FieldOptions::new(Lang::Python) + }; + let out = code_editor::code_field(ui, id, &mut ed.text, &options); + if !blocked && caret.is_none() { + (ed.caret, ed.anchor) = out.selection; + } + let info = EditorInfo { lines: out.lines, caret: out.caret }; + app.editor = Some(ed); + match out.stop.as_ref().and_then(python_key) { + Some(PythonKey::Commit) => { + app.commit_python_edit(); + } + Some(PythonKey::Cancel) => app.cancel_edit(), + Some(PythonKey::Leave) => app.toggle_python_mode(), + None => {} + } + info +} + fn cycle_anchor(ed: &mut crate::editor::EditState) { let caret_b = ed.text.char_indices().nth(ed.caret).map(|(i, _)| i).unwrap_or(ed.text.len()); for (a, b, ..) in formula_refs(&ed.text) { diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index ea5daf33c..5217724c0 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -39,6 +39,8 @@ pub enum Shortcut { DropDown, /// Ctrl+Alt+= / Ctrl+Alt+-: zoom in / out. Zoom(i32), + /// Ctrl+Alt+Shift+P: edit the active cell as Python code (Python mode). + PythonMode, } fn run(id: &'static str) -> Shortcut { @@ -219,6 +221,8 @@ fn lookup_control_alt_shortcuts(ctx: LookupContext) -> Option { Equals | Plus if ctrl_alt => Shortcut::Zoom(10), Minus if ctrl_alt && !mac => Shortcut::Zoom(-10), F9 if ctrl_alt => run("formulas.calculateNow"), + // Python in cells (Cmd+Option on macOS too). + P if ctrl_alt && s => Shortcut::PythonMode, R if ctrl_alt && mac => run("view.collapseRibbon"), Num0 if ctrl_alt && mac => borders("outside"), Minus if ctrl_alt && mac => borders("none"), @@ -342,6 +346,7 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic app.grid.reveal_from = from; } Shortcut::Edit => app.begin_edit(None, false), + Shortcut::PythonMode => app.toggle_python_mode(), Shortcut::ContextMenu => app.grid.context_menu = Some(geo.cell_rect(sh, active).center()), Shortcut::DropDown => { if sh.validations.iter().any(|d| d.in_cell_dropdown && d.ranges.iter().any(|r| r.contains(active))) { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index f37eb0c35..d79aa6a29 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -114,6 +114,13 @@ pub struct Tokens { pub sheet_macro_footprint: Color32, pub sheet_macro_conflict: Color32, pub sheet_macro_target: Color32, + /// The PY badge in the formula bar (Python mode) and its text. + pub badge_py: Color32, + pub on_badge: Color32, + /// The PY tag on a Python cell and its text. They follow the sheet, so they are the same in + /// both themes. + pub sheet_py_tag: Color32, + pub on_sheet_py_tag: Color32, } /// The eight category colours, in the order of [`Tokens::cat`]. @@ -165,6 +172,8 @@ const SHEET_CONTROL_BG: Color32 = rgb(0xF3F3F3); const SHEET_CONTROL_BORDER: Color32 = rgb(0x8A8A8A); const SHEET_CONTROL_TEXT: Color32 = rgb(0x1F1F1F); const SHEET_TRACK: Color32 = rgb(0xD6D6D6); +/// The PY tag on Python cells: a deep green, white text on it. +const SHEET_PY_TAG: Color32 = rgb(0x1E6E4E); impl Tokens { pub fn light() -> Tokens { @@ -244,6 +253,10 @@ impl Tokens { sheet_macro_footprint: SHEET_MACRO_FOOTPRINT, sheet_macro_conflict: SHEET_MACRO_CONFLICT, sheet_macro_target: SHEET_MACRO_TARGET, + badge_py: pick(dark, SHEET_PY_TAG, rgb(0x5CBF94)), + on_badge: pick(dark, Color32::WHITE, rgb(0x0B1F17)), + sheet_py_tag: SHEET_PY_TAG, + on_sheet_py_tag: Color32::WHITE, } } @@ -401,6 +414,11 @@ mod contrast_tests { add("text on current step", t.text, t.accent_soft, TEXT); add("current step arrow", t.accent, t.accent_soft, GLYPH); add("error marker on window", t.danger, t.window, GLYPH); + add("PY badge text", t.on_badge, t.badge_py, TEXT); + add("PY badge on window", t.badge_py, t.window, GLYPH); + add("PY badge on input", t.badge_py, t.input_bg, GLYPH); + add("PY tag text", t.on_sheet_py_tag, t.sheet_py_tag, TEXT); + add("PY tag on sheet", t.sheet_py_tag, t.grid_bg, GLYPH); v } diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index a70dc449b..a6a029df3 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -83,6 +83,8 @@ mod popup_dismissal; mod protect_password; mod protected_cell; mod python_cell_edit; +mod python_hidden; +mod python_mode; mod ribbon_layout; mod right_to_left; mod scroll_position; diff --git a/crates/ui-egui/tests/it/python_cell_edit.rs b/crates/ui-egui/tests/it/python_cell_edit.rs index 3153299c5..66a922b04 100644 --- a/crates/ui-egui/tests/it/python_cell_edit.rs +++ b/crates/ui-egui/tests/it/python_cell_edit.rs @@ -1,5 +1,5 @@ -//! Editing a Python cell's content (F2, the formula bar) edits its code; typing over it replaces -//! it, as typing over a formula does. +//! Editing a Python cell's content (F2, the formula bar) edits its code, in Python mode; typing +//! over it replaces it, as typing over a formula does. use egui::{Event, Key, Modifiers}; use gridcraft_engine::Session; @@ -22,13 +22,22 @@ fn text(h: &mut Harness, text: &str) { h.run_steps(2); } -fn key(h: &mut Harness, key: Key) { +fn key_with(h: &mut Harness, key: Key, modifiers: Modifiers) { for pressed in [true, false] { - h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); } h.run_steps(2); } +fn key(h: &mut Harness, key: Key) { + key_with(h, key, Modifiers::NONE); +} + +/// Ctrl+Enter (Cmd+Enter on macOS): commits Python mode, where Enter starts a new line. +fn commit(h: &mut Harness) { + key_with(h, Key::Enter, Modifiers::COMMAND); +} + /// A1's code and return type (true: a Python object), or `None` when it isn't a Python cell. fn python(h: &Harness) -> Option<(String, bool)> { let sh = h.state().session.active().unwrap().wb.active().unwrap(); @@ -46,10 +55,10 @@ fn f2_edits_a_python_cells_code_and_typing_replaces_it() { let undo_depth = |h: &Harness| h.state().session.active().unwrap().undo.len(); let before = undo_depth(&h); - // F2 and Enter without a change writes nothing. + // F2 and a commit without a change writes nothing. key(&mut h, Key::F2); - assert_eq!(h.state().editor.as_ref().map(|e| e.text.as_str()), Some("1 + 1")); - key(&mut h, Key::Enter); + assert_eq!(h.state().editor.as_ref().map(|e| (e.text.as_str(), e.python)), Some(("1 + 1", true))); + commit(&mut h); assert!(h.state().editor.is_none()); assert_eq!(undo_depth(&h), before, "an unchanged edit is no undo step"); assert_eq!(python(&h), Some(("1 + 1".into(), true))); @@ -58,7 +67,7 @@ fn f2_edits_a_python_cells_code_and_typing_replaces_it() { select_a1(&mut h); key(&mut h, Key::F2); text(&mut h, " + 2"); - key(&mut h, Key::Enter); + commit(&mut h); assert!(h.state().editor.is_none(), "{:?}", h.state().message); assert_eq!(python(&h), Some(("1 + 1 + 2".into(), true)), "the cell stays a Python cell with the new code"); @@ -123,6 +132,6 @@ fn a_code_edit_is_never_completed_from_the_column() { key(&mut h, Key::F2); text(&mut h, " "); assert_eq!(h.state().editor.as_ref().and_then(|e| e.completion.clone()), None, "no completion while code is edited"); - key(&mut h, Key::Enter); + commit(&mut h); assert_eq!(python(&h), Some(("1 + 1 ".into(), true)), "the typed code is kept"); } diff --git a/crates/ui-egui/tests/it/python_hidden.rs b/crates/ui-egui/tests/it/python_hidden.rs new file mode 100644 index 000000000..705728d6c --- /dev/null +++ b/crates/ui-egui/tests/it/python_hidden.rs @@ -0,0 +1,137 @@ +//! A Python cell formatted Hidden on a protected sheet keeps its code out of view, as a hidden +//! formula does: the formula bar shows nothing, and F2, a double-click, the formula bar, +//! Ctrl+Alt+Shift+P and the PY badge start no edit that would show the code, or +//! that would write blank code over it. + +use egui::{Event, Key, Modifiers, vec2}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use serde_json::json; + +use crate::common::Harness; + +const CODE: &str = "secret_total = 41\nsecret_total"; + +/// A Python cell at B2, selected, with its protection `hidden` flag, on a sheet protected or not. +fn harness(hidden: bool, protected: bool) -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("python.set", json!({"cell": "B2", "code": CODE})).unwrap(); + s.execute("home.formatCells", json!({"range": "B2", "style": {"protection": {"locked": false, "hidden": hidden}}})).unwrap(); + if protected { + s.execute("review.protectSheet", json!({})).unwrap(); + } + s.execute("selection.set", json!({"cell": "B2"})).unwrap(); + crate::common::harness_with(crate::common::builder().with_size(vec2(1200.0, 800.0)), crate::common::app(s), 4) +} + +fn send(h: &mut Harness, events: impl IntoIterator) { + h.input_mut().events.extend(events); + h.run_steps(3); +} + +fn press(h: &mut Harness, key: Key, modifiers: Modifiers) { + send( + h, + [ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ], + ); +} + +const CTRL_ALT_SHIFT: Modifiers = Modifiers { alt: true, ctrl: true, shift: true, mac_cmd: false, command: true }; + +/// Two clicks on B2 in a row: a double-click on the grid. +fn double_click_b2(h: &mut Harness) { + let at = { + let app = h.state(); + let sh = app.session.active().unwrap().wb.active().unwrap(); + app.grid.geo.as_ref().unwrap().screen_cell_rect(sh, CellRef::parse("B2").unwrap()).center() + }; + for _ in 0..2 { + for event in [ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ] { + h.input_mut().events.push(event); + h.run_steps(1); + } + } + h.run_steps(2); +} + +/// The open edit's text, and whether it is in Python mode. +fn editor(h: &Harness) -> Option<(String, bool)> { + h.state().editor.as_ref().map(|e| (e.text.clone(), e.python)) +} + +fn bar_shows_code(h: &Harness) -> bool { + h.query_by_label_contains("secret_total").is_some() +} + +fn stored_code(h: &Harness) -> Option { + let d = h.state().session.active()?; + d.wb.active()?.cell(CellRef::parse("B2")?)?.py.as_ref().map(|p| p.code.clone()) +} + +fn cancel(h: &mut Harness) { + h.state_mut().cancel_edit(); + h.run_steps(2); +} + +#[test] +fn a_hidden_python_cell_on_a_protected_sheet_shows_no_code_in_the_formula_bar() { + let h = harness(true, true); + assert!(!bar_shows_code(&h), "the formula bar shows nothing for hidden code"); + assert!(h.query_by_label("Python Mode").is_some(), "the PY badge still marks a Python cell"); +} + +#[test] +fn f2_a_double_click_and_the_formula_bar_start_no_edit_on_hidden_code() { + let mut h = harness(true, true); + press(&mut h, Key::F2, Modifiers::NONE); + assert_eq!(editor(&h), None, "F2"); + double_click_b2(&mut h); + assert_eq!(editor(&h), None, "a double-click"); + h.state_mut().begin_edit(None, true); + assert_eq!(editor(&h), None, "a click in the formula bar"); + assert!(!bar_shows_code(&h)); + assert_eq!(stored_code(&h).as_deref(), Some(CODE), "the code is untouched"); +} + +#[test] +fn python_mode_starts_no_edit_on_hidden_code() { + let mut h = harness(true, true); + press(&mut h, Key::P, CTRL_ALT_SHIFT); + assert_eq!(editor(&h), None, "Ctrl+Alt+Shift+P"); + h.get_by_label("Python Mode").click(); + h.run_steps(3); + assert_eq!(editor(&h), None, "the PY badge"); + assert_eq!(h.state_mut().run("ui.pythonMode", json!({"on": true})).unwrap()["on"], false, "ui.pythonMode"); + assert!(!bar_shows_code(&h)); + // Ctrl+Enter with no edit open writes nothing over the code. + press(&mut h, Key::Enter, Modifiers::COMMAND); + assert_eq!(stored_code(&h).as_deref(), Some(CODE)); +} + +/// The same cell, unprotected or not hidden, shows and edits its code. +#[test] +fn the_code_shows_and_edits_when_the_sheet_is_unprotected_or_the_cell_not_hidden() { + for (hidden, protected) in [(true, false), (false, true)] { + let what = format!("hidden {hidden}, protected {protected}"); + let mut h = harness(hidden, protected); + assert!(bar_shows_code(&h), "{what}: the formula bar shows the code"); + press(&mut h, Key::F2, Modifiers::NONE); + assert_eq!(editor(&h), Some((CODE.to_string(), true)), "{what}: F2"); + cancel(&mut h); + double_click_b2(&mut h); + assert_eq!(editor(&h), Some((CODE.to_string(), true)), "{what}: a double-click"); + cancel(&mut h); + press(&mut h, Key::P, CTRL_ALT_SHIFT); + assert_eq!(editor(&h), Some((CODE.to_string(), true)), "{what}: Ctrl+Alt+Shift+P"); + cancel(&mut h); + } +} diff --git a/crates/ui-egui/tests/it/python_mode.rs b/crates/ui-egui/tests/it/python_mode.rs new file mode 100644 index 000000000..6e88e39d4 --- /dev/null +++ b/crates/ui-egui/tests/it/python_mode.rs @@ -0,0 +1,409 @@ +//! Python mode in the formula bar: entering and leaving it, its keys (applied in input order, also +//! several in one frame), committing through `python.set`, and the PY badge. + +use egui::{Event, Key, Modifiers, vec2}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellError, CellRef, Value}; +use serde_json::json; + +use crate::common::Harness; + +fn blank() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s +} + +fn harness(session: Session) -> Harness { + crate::common::harness_with(crate::common::builder().with_size(vec2(1200.0, 800.0)), crate::common::app(session), 4) +} + +fn mac_harness(session: Session) -> Harness { + crate::common::os_harness(session, egui::os::OperatingSystem::Mac) +} + +fn press(key: Key, modifiers: Modifiers) -> [Event; 2] { + [ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ] +} + +/// Sends `events` in one frame, then lets the app settle. +fn send(h: &mut Harness, events: impl IntoIterator) { + h.input_mut().events.extend(events); + h.run_steps(3); +} + +fn type_text(h: &mut Harness, text: &str) { + send(h, [Event::Text(text.into())]); +} + +const CTRL_ALT_SHIFT: Modifiers = Modifiers { alt: true, ctrl: true, shift: true, mac_cmd: false, command: true }; +const CMD_OPTION_SHIFT: Modifiers = Modifiers { alt: true, ctrl: false, shift: true, mac_cmd: true, command: true }; + +fn shortcut(h: &mut Harness) { + send(h, press(Key::P, CTRL_ALT_SHIFT)); +} + +fn ctrl_enter(h: &mut Harness) { + send(h, press(Key::Enter, Modifiers::COMMAND)); +} + +/// Whether an edit is open, and in Python mode. +fn mode(h: &Harness) -> Option { + h.state().editor.as_ref().map(|e| e.python) +} + +fn text(h: &Harness) -> String { + h.state().editor.as_ref().map(|e| e.text.clone()).unwrap_or_default() +} + +fn code(h: &Harness, cell: &str) -> Option { + let d = h.state().session.active()?; + d.wb.active()?.cell(CellRef::parse(cell)?)?.py.as_ref().map(|p| p.code.clone()) +} + +fn value(h: &Harness, cell: &str) -> Value { + let d = h.state().session.active().unwrap(); + d.wb.active().unwrap().value(CellRef::parse(cell).unwrap()) +} + +#[test] +fn the_shortcut_enters_and_leaves_python_mode_and_esc_cancels() { + let mut h = harness(blank()); + shortcut(&mut h); + assert_eq!(mode(&h), Some(true), "Ctrl+Alt+Shift+P starts Python mode on the active cell"); + let ed = h.state().editor.as_ref().unwrap(); + assert!(ed.from_formula_bar, "Python mode edits in the formula bar"); + assert_eq!(ed.cell, CellRef::new(0, 0)); + h.check_accessibility(); + type_text(&mut h, "x = 1"); + assert_eq!(text(&h), "x = 1"); + shortcut(&mut h); + assert_eq!(mode(&h), Some(false), "the shortcut again leaves Python mode"); + assert_eq!(text(&h), "x = 1", "and the edit keeps its text"); + shortcut(&mut h); + assert_eq!(mode(&h), Some(true)); + send(&mut h, press(Key::Escape, Modifiers::NONE)); + assert_eq!(mode(&h), None, "Esc cancels the edit"); + assert_eq!(code(&h, "A1"), None, "and changes nothing"); + assert_eq!(value(&h, "A1"), Value::Empty); +} + +#[test] +fn the_mac_shortcut_is_cmd_option_shift_p_and_cmd_enter_commits() { + let mut h = mac_harness(blank()); + send(&mut h, press(Key::P, CMD_OPTION_SHIFT)); + assert_eq!(mode(&h), Some(true)); + type_text(&mut h, "1 + 1"); + send(&mut h, press(Key::Enter, Modifiers::MAC_CMD | Modifiers::COMMAND)); + assert_eq!(mode(&h), None); + assert_eq!(code(&h, "A1").as_deref(), Some("1 + 1")); +} + +#[test] +fn typing_py_then_tab_enters_python_mode_as_in_excel() { + let mut h = harness(blank()); + type_text(&mut h, "=PY"); + assert_eq!(mode(&h), Some(false), "typing starts an ordinary entry in the cell"); + send(&mut h, press(Key::Tab, Modifiers::NONE)); + assert_eq!(mode(&h), Some(true), "=PY then Tab is Python mode"); + assert_eq!(text(&h), "", "the trigger is not code"); + assert!(h.state().editor.as_ref().is_some_and(|e| e.from_formula_bar)); + let active = h.state().session.active().map(|d| d.selection.active); + assert_eq!(active, Some(CellRef::new(0, 0)), "Tab did not move on"); + type_text(&mut h, "len('abc')"); + ctrl_enter(&mut h); + assert_eq!(code(&h, "A1").as_deref(), Some("len('abc')")); + assert_eq!(value(&h, "A1"), Value::Error(CellError::Blocked), "this build stores the code and doesn't run it"); + // Any other entry still commits with Tab. + h.state_mut().session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + h.run_steps(2); + type_text(&mut h, "=PI"); + send(&mut h, press(Key::Tab, Modifiers::NONE)); + assert_eq!(mode(&h), None); +} + +#[test] +fn enter_and_tab_edit_the_code_and_ctrl_enter_commits_through_python_set() { + let mut h = harness(blank()); + h.state_mut().session.execute("selection.set", json!({"cell": "C3"})).unwrap(); + shortcut(&mut h); + type_text(&mut h, "for i in range(3):"); + send(&mut h, press(Key::Enter, Modifiers::NONE)); + type_text(&mut h, "print(i)"); + send(&mut h, press(Key::Enter, Modifiers::NONE)); + send(&mut h, press(Key::Tab, Modifiers::SHIFT)); + type_text(&mut h, "x"); + assert_eq!(text(&h), "for i in range(3):\n print(i)\nx", "Enter keeps the indentation, Shift+Tab takes it back"); + send(&mut h, press(Key::Tab, Modifiers::NONE)); + assert_eq!(text(&h), "for i in range(3):\n print(i)\nx "); + assert_eq!(mode(&h), Some(true), "Enter and Tab stay in the edit"); + ctrl_enter(&mut h); + assert_eq!(mode(&h), None); + assert_eq!(code(&h, "C3").as_deref(), Some("for i in range(3):\n print(i)\nx ")); + assert_eq!(h.state().session.active().map(|d| d.selection.active), Some(CellRef::new(2, 2)), "Ctrl+Enter stays on the cell"); + // One undo takes the code away. + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(code(&h, "C3"), None); +} + +#[test] +fn several_keys_in_one_frame_are_applied_in_order() { + let mut h = harness(blank()); + shortcut(&mut h); + type_text(&mut h, "a"); + send(&mut h, press(Key::Enter, Modifiers::NONE)); + type_text(&mut h, "b"); + // Select all, then Tab, in one frame: Tab indents the selected lines rather than replacing + // them (the #456 review regression). + let mut events = Vec::new(); + events.extend(press(Key::A, Modifiers::COMMAND)); + events.extend(press(Key::Tab, Modifiers::NONE)); + send(&mut h, events); + assert_eq!(text(&h), " a\n b"); + let mut events = Vec::new(); + events.extend(press(Key::A, Modifiers::COMMAND)); + events.extend(press(Key::Tab, Modifiers::SHIFT)); + send(&mut h, events); + assert_eq!(text(&h), "a\nb", "Shift+Tab after select-all dedents every line"); + // A keystroke sequence as the platform sends it: a key before each text, then Enter. + send(&mut h, press(Key::End, Modifiers::COMMAND)); + let mut events = Vec::new(); + for (k, c) in [(Key::I, "i"), (Key::F, "f"), (Key::Space, " "), (Key::Y, "y")] { + events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + events.push(Event::Text(c.into())); + } + events.push(Event::Key { key: Key::Colon, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }); + events.push(Event::Text(":".into())); + events.extend(press(Key::Enter, Modifiers::NONE)); + events.push(Event::Text("z".into())); + send(&mut h, events); + assert_eq!(text(&h), "a\nbif y:\n z"); + // Select all, Tab and Ctrl+Enter in one frame: the commit waits for the Tab. + let mut events = Vec::new(); + events.extend(press(Key::A, Modifiers::COMMAND)); + events.extend(press(Key::Tab, Modifiers::NONE)); + events.extend(press(Key::Enter, Modifiers::COMMAND)); + send(&mut h, events); + assert_eq!(mode(&h), None); + assert_eq!(code(&h, "A1").as_deref(), Some(" a\n bif y:\n z")); +} + +#[test] +fn keys_typed_after_the_commit_key_in_the_same_frame_are_not_committed() { + let mut h = harness(blank()); + shortcut(&mut h); + let mut events = vec![Event::Text("1".into())]; + events.extend(press(Key::Enter, Modifiers::COMMAND)); + events.push(Event::Text("2".into())); + send(&mut h, events); + assert_eq!(code(&h, "A1").as_deref(), Some("1")); + assert_eq!(value(&h, "B1"), Value::Empty); + assert!(h.state().editor.is_none(), "the text after the commit key starts nothing"); +} + +#[test] +fn code_too_long_for_a_cell_shows_the_commands_message_and_keeps_the_code() { + let mut h = harness(blank()); + shortcut(&mut h); + let long = "y".repeat(8_080); + if let Some(ed) = h.state_mut().editor.as_mut() { + ed.text = long.clone(); + ed.caret = long.chars().count(); + ed.sync_caret = true; + } + h.run_steps(2); + ctrl_enter(&mut h); + let message = h.state().message.clone().map(|m| m.1).unwrap_or_default(); + assert!(message.contains("8192"), "the command's message: {message}"); + assert_eq!(mode(&h), Some(true), "the edit stays open in Python mode"); + assert_eq!(text(&h), long, "with the code as typed"); + assert_eq!(code(&h, "A1"), None); + // Keys go to the message box, not the code, until it closes. + send(&mut h, press(Key::Enter, Modifiers::NONE)); + assert!(h.state().message.is_none(), "Enter closed the message box"); + assert_eq!(text(&h), long, "and added no line to the code"); + // The editor has the keyboard back: shortening the code commits it. + send(&mut h, press(Key::Backspace, Modifiers::NONE)); + assert_eq!(text(&h).len(), 8_079); + ctrl_enter(&mut h); + assert_eq!(code(&h, "A1").map(|c| c.len()), Some(8_079)); +} + +#[test] +fn editing_a_python_cell_edits_its_code_and_typing_replaces_it() { + let mut s = blank(); + s.execute("python.set", json!({"cell": "B2", "code": "x = 1\nx"})).unwrap(); + s.execute("selection.set", json!({"cell": "B2"})).unwrap(); + let mut h = harness(s); + // The formula bar shows the code, with the PY badge. + assert!(h.query_by_label("Python Mode").is_some(), "the PY badge shows on a selected Python cell"); + send(&mut h, press(Key::F2, Modifiers::NONE)); + assert_eq!(mode(&h), Some(true), "F2 on a Python cell is Python mode"); + assert_eq!(text(&h), "x = 1\nx"); + // The badge leaves Python mode, as the shortcut does. + h.get_by_label("Python Mode").click(); + h.run_steps(3); + assert_eq!(mode(&h), Some(false)); + send(&mut h, press(Key::Escape, Modifiers::NONE)); + assert_eq!(mode(&h), None); + assert!(h.query_by_label("Python Mode").is_some()); + // Typing replaces the cell, as in Excel. + type_text(&mut h, "7"); + assert_eq!(mode(&h), Some(false)); + send(&mut h, press(Key::Enter, Modifiers::NONE)); + assert_eq!(code(&h, "B2"), None); + assert_eq!(value(&h, "B2"), Value::Number(7.0)); + // No badge on an ordinary cell. + h.state_mut().session.execute("selection.set", json!({"cell": "B2"})).unwrap(); + h.run_steps(2); + assert!(h.query_by_label("Python Mode").is_none()); +} + +#[test] +fn ui_python_mode_switches_from_the_control_channel_without_a_dialog() { + let mut h = harness(blank()); + let r = h.state_mut().run("ui.pythonMode", json!({})).unwrap(); + assert_eq!(r["on"], true); + assert_eq!(h.state_mut().run("ui.pythonMode", json!({"on": true})).unwrap()["on"], true, "already on"); + assert_eq!(h.state_mut().run("ui.pythonMode", json!({"on": false})).unwrap()["on"], false); + assert!(h.state().dialog.is_none() && h.state().message.is_none()); + h.state_mut().cancel_edit(); + assert_eq!(h.state_mut().run("ui.pythonMode", json!({"on": false})).unwrap()["on"], false, "off with no edit does nothing"); + assert!(h.state().editor.is_none()); +} + +/// Clicks the middle of `a1` on screen. +fn click_cell(h: &mut Harness, a1: &str) { + let at = { + let app = h.state(); + let sh = app.session.active().unwrap().wb.active().unwrap(); + app.grid.geo.as_ref().unwrap().screen_cell_rect(sh, CellRef::parse(a1).unwrap()).center() + }; + for event in [ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ] { + h.input_mut().events.push(event); + h.run_steps(1); + } + h.run_steps(2); +} + +#[test] +fn the_enter_button_and_a_click_on_the_sheet_commit_the_code_through_python_set() { + let mut h = harness(blank()); + shortcut(&mut h); + type_text(&mut h, "'=SUM(1)'"); + h.get_by_label("Enter").click(); + h.run_steps(3); + assert_eq!(code(&h, "A1").as_deref(), Some("'=SUM(1)'"), "the code, not a formula or text"); + h.state_mut().session.execute("selection.set", json!({"cell": "A2"})).unwrap(); + shortcut(&mut h); + type_text(&mut h, "2"); + click_cell(&mut h, "D4"); + assert_eq!(code(&h, "A2").as_deref(), Some("2")); + assert_eq!(h.state().session.active().map(|d| d.selection.active), Some(CellRef::new(3, 3))); + assert!(h.state().editor.is_none()); +} + +#[test] +fn the_formula_bar_shows_while_python_mode_is_open_when_the_preference_hides_it() { + let mut h = harness(blank()); + h.state_mut().session.prefs.formula_bar = false; + h.run_steps(2); + shortcut(&mut h); + assert_eq!(mode(&h), Some(true)); + type_text(&mut h, "x = 1"); + assert_eq!(text(&h), "x = 1", "the code editor is drawn and takes the typing"); + ctrl_enter(&mut h); + assert!(h.state().editor.is_none()); + assert_eq!(code(&h, "A1").as_deref(), Some("x = 1")); + assert!(!h.state().session.prefs.formula_bar, "the preference stays as it was"); +} + +#[test] +fn committing_no_code_clears_a_python_cell_and_writes_nothing_elsewhere() { + let mut s = blank(); + s.execute("python.set", json!({"cell": "A1", "code": "1 + 1"})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "5"})).unwrap(); + let mut h = harness(s); + // A Python cell whose code is deleted is cleared, as an empty entry clears any other cell. + shortcut(&mut h); + h.state_mut().editor.as_mut().unwrap().text.clear(); + ctrl_enter(&mut h); + assert!(h.state().editor.is_none(), "{:?}", h.state().message); + assert_eq!(code(&h, "A1"), None); + assert_eq!(value(&h, "A1"), Value::Empty); + // Python mode on a value cell, committed with no code: the value stays. + h.state_mut().run("selection.set", json!({"range": "A2"})).unwrap(); + h.run_steps(2); + shortcut(&mut h); + assert_eq!(text(&h), ""); + ctrl_enter(&mut h); + assert!(h.state().editor.is_none()); + assert_eq!(code(&h, "A2"), None); + assert_eq!(value(&h, "A2"), Value::Number(5.0)); +} + +#[test] +fn a_python_draft_belongs_to_the_window_it_was_typed_in() { + let mut s = blank(); + s.execute("python.set", json!({"cell": "B1", "code": "2"})).unwrap(); + let mut h = harness(s); + let first = h.state().session.active_window().unwrap(); + h.state_mut().run_or_alert("view.newWindow", json!({})); + h.run_steps(2); + let second = h.state().session.active_window().unwrap(); + assert_ne!(first, second); + // Both ways into Python mode record the window, as an ordinary edit does. + shortcut(&mut h); + assert_eq!(h.state().editor.as_ref().and_then(|e| e.window), Some(second)); + send(&mut h, press(Key::Escape, Modifiers::NONE)); + h.state_mut().run("selection.set", json!({"range": "B1"})).unwrap(); + send(&mut h, press(Key::F2, Modifiers::NONE)); + assert_eq!(mode(&h), Some(true)); + assert_eq!(h.state().editor.as_ref().and_then(|e| e.window), Some(second)); + // A script brings window 1 forward; the draft is committed in its own window, and when its + // workbook refuses it (too long for a cell), that window comes back with the draft open. + h.state_mut().editor.as_mut().unwrap().text = "y".repeat(9_000); + h.state_mut().session.activate_window(first); + assert!(!h.state_mut().commit_edit(0, 0, false, false)); + assert_eq!(h.state().session.active_window(), Some(second)); + assert_eq!(mode(&h), Some(true), "the draft is still open"); +} + +/// A locked cell on a protected sheet: Python mode is refused before a draft exists, and a draft +/// whose cell sheet protection locks while it is open is dropped with the warning, as an ordinary +/// edit's is, so no draft is left that can never be entered and blocks Save (#2945). +#[test] +fn python_mode_leaves_no_draft_on_a_locked_cell() { + let mut s = blank(); + s.execute("python.set", json!({"cell": "A1", "code": "1"})).unwrap(); + s.execute("python.set", json!({"cell": "B1", "code": "2"})).unwrap(); + s.execute("home.formatCells", json!({"range": "B1", "style": {"protection": {"locked": false}}})).unwrap(); + s.execute("review.protectSheet", json!({})).unwrap(); + let mut h = harness(s); + shortcut(&mut h); + assert_eq!(mode(&h), None, "no draft on the locked A1"); + assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("protected sheet")), "{:?}", h.state().message); + h.state_mut().run("ui.message.dismiss", json!({})).unwrap(); + // The unlocked B1 edits; then protection locks it while the draft is open. + h.state_mut().run("selection.set", json!({"range": "B1"})).unwrap(); + h.run_steps(2); + shortcut(&mut h); + assert_eq!(mode(&h), Some(true)); + type_text(&mut h, " + 1"); + h.state_mut().run("review.unprotectSheet", json!({})).unwrap(); + h.state_mut().run("home.formatCells", json!({"range": "B1", "style": {"protection": {"locked": true}}})).unwrap(); + h.state_mut().run("review.protectSheet", json!({})).unwrap(); + ctrl_enter(&mut h); + assert_eq!(mode(&h), None, "the draft is dropped, not left open"); + assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("protected sheet"))); + assert_eq!(code(&h, "B1").as_deref(), Some("2")); + assert!(h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).is_ok()); +}