From 31d89140c367469d33eebf8e18bc0ac78b6b8504 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:48:48 -0400 Subject: [PATCH 1/4] UI: code editor line-number gutter, and edits the caller can refuse code_field gains three options for a larger editor. gutter puts line numbers at the left of the code, inside the scroll area so they scroll with it: one number per line of text, on the first row of a wrapped line, right-aligned, with a rule between them and the code. Its width is the last line's number of digits in the code font, so it widens at 10, 100 and 1,000 lines. Only the rows in view are painted: the rows above are skipped with a binary search on their positions, so long code costs the rows on screen. fill makes the field as tall as its scroll area, so a click below the last line still lands in it. accept lets the caller refuse an edit (it gets the text after and the text before). apply_in_order_within checks each of the editor's own edits on its own, in input order: a refused one is undone at once and the pass goes on, so typing up to a limit keeps every character that fits and a paste that would pass it is refused whole. The text field's own edits (IME, undo, redo) are checked after it runs and undone the same way. FieldOutput reports the gutter's width and whether an edit was refused. Pasted text with Windows (\r\n) or old Mac (\r) line breaks is inserted with \n, as a cell's code is stored; a lone \r has no glyph. The line helpers are pure functions with unit tests (line_count, gutter_digits, the bounded apply, the pasted line breaks). There is no error underline: nothing computes byte offsets into the text. --- crates/ui-egui/src/code_editor.rs | 233 +++++++++++++++++++++++++++--- 1 file changed, 211 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/code_editor.rs b/crates/ui-egui/src/code_editor.rs index 36e6e992a5..2d8a16a459 100644 --- a/crates/ui-egui/src/code_editor.rs +++ b/crates/ui-egui/src/code_editor.rs @@ -350,6 +350,9 @@ fn is_keyboard(ev: &egui::Event) -> bool { fn apply(key: EditKey<'_>, text: &mut String, sel: (usize, usize), lang: Lang) -> Edit { match key { + // Pasted text keeps its platform's line breaks (`\r\n` on Windows); code has `\n` only, as + // a cell's code is stored, and a lone `\r` has no glyph. + EditKey::Insert(s) if s.contains('\r') => insert(text, sel, &s.replace("\r\n", "\n").replace('\r', "\n")), EditKey::Insert(s) => insert(text, sel, s), EditKey::Enter => newline(text, sel, lang), EditKey::Tab { dedent } => indent(text, sel, dedent), @@ -368,8 +371,14 @@ pub struct Applied { pub held: Vec, /// The caller's key the pass stopped at (see [`apply_in_order_until`]). pub stop: Option, + /// An edit was refused: it would have made text the caller does not accept (see + /// [`apply_in_order_within`]). + pub refused: bool, } +/// Whether the caller accepts an edit: the text after it, then the text before it. +pub type Accept<'a> = &'a dyn Fn(&str, &str) -> bool; + /// Applies the pass's input to `text` in the order it came. A pass can carry several events /// (typing between repaints, key repeat), and two readers take them: this editor, for typed /// text, pastes, Enter, Tab, Shift+Tab, Backspace and Delete, and then egui's text field, for @@ -399,6 +408,21 @@ pub fn apply_in_order_until( sel: &mut (usize, usize), lang: Lang, stop: &dyn Fn(&egui::Event) -> bool, +) -> Applied { + apply_in_order_within(events, text, sel, lang, stop, None) +} + +/// [`apply_in_order_until`], where each of the editor's edits is checked with `accept` (the text +/// after it, the text before it): an edit it refuses is undone at once, so the text and the +/// selection stay as they were, and the pass goes on with the next event (`Applied::refused`). +/// Typing and pasting past a limit are refused this way, one edit at a time. +pub fn apply_in_order_within( + events: &mut Vec, + text: &mut String, + sel: &mut (usize, usize), + lang: Lang, + stop: &dyn Fn(&egui::Event) -> bool, + accept: Option>, ) -> 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. @@ -432,9 +456,18 @@ pub fn apply_in_order_until( out.held.push(ev); } Class::Edit(key) => { + let before = accept.map(|_| text.clone()); let e = apply(key, text, *sel, lang); - *sel = (e.anchor, e.caret); - out.edited = true; + match (accept, before) { + (Some(ok), Some(before)) if !ok(text, &before) => { + *text = before; + out.refused = true; + } + _ => { + *sel = (e.anchor, e.caret); + out.edited = true; + } + } } Class::Dropped => {} Class::Field => { @@ -543,14 +576,22 @@ pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max /// 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). + /// The field's width (`f32::INFINITY`: all there is), its line-number gutter included. pub width: f32, /// The height shown before it scrolls; the scroll area follows the caret. pub max_height: f32, + /// The field is `max_height` tall even when its text is shorter (a click below the text still + /// lands in it). + pub fill: bool, + /// A line-number gutter at the left, scrolled with the text (see [`gutter_width`]). + pub gutter: bool, /// 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, + /// Edits the caller accepts (the text after, the text before); the others are refused and + /// undone, typing and pasting alike (see [`apply_in_order_within`]). `None`: every edit. + pub accept: Option>, /// 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. @@ -565,8 +606,11 @@ impl FieldOptions<'_> { lang, width: f32::INFINITY, max_height: 100.0, + fill: false, + gutter: false, interactive: true, stop: &|_| false, + accept: None, keep_escape: false, caret: None, // The editor is a generic code field, so the name is not part of the UI's translated @@ -587,6 +631,10 @@ pub struct FieldOutput { pub caret: Option, /// The caret and the other end of the selection (char indices). pub selection: (usize, usize), + /// The line-number gutter's width (0 without one). + pub gutter: f32, + /// An edit of this pass was refused by [`FieldOptions::accept`] and undone. + pub refused: bool, } /// The code editor's text field without a frame: monospace text with syntax colours, Enter that @@ -602,6 +650,7 @@ pub fn code_field(ui: &mut Ui, id: egui::Id, text: &mut String, options: &FieldO let held = take_held(ui, id, focused); let mut edited = None; let mut stop = None; + let mut refused = false; let count = text.chars().count(); if let Some(caret) = options.caret { let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); @@ -610,6 +659,9 @@ pub fn code_field(ui: &mut Ui, id: egui::Id, text: &mut String, options: &FieldO state.store(ui.ctx(), id); edited = Some(Edit { anchor: caret, caret }); } + // The text and selection the text field starts from, for undoing an edit of its own (an IME + // commit, undo, redo) that the caller refuses. + let mut field_start = None; if focused { let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); let mut sel = state.cursor.char_range().map(|r| (r.secondary.index.into(), r.primary.index.into())).unwrap_or((count, count)); @@ -619,7 +671,7 @@ pub fn code_field(ui: &mut Ui, id: egui::Id, text: &mut String, options: &FieldO events.append(&mut i.events); i.events = events; } - apply_in_order_until(&mut i.events, text, &mut sel, lang, options.stop) + apply_in_order_within(&mut i.events, text, &mut sel, lang, options.stop, options.accept) }); if applied.edited { state.cursor.set_char_range(Some(CCursorRange::two(CCursor::new(sel.0), CCursor::new(sel.1)))); @@ -630,6 +682,10 @@ pub fn code_field(ui: &mut Ui, id: egui::Id, text: &mut String, options: &FieldO hold(ui, id, applied.held); } stop = applied.stop; + refused = applied.refused; + if options.accept.is_some() { + field_start = Some((text.clone(), sel)); + } } let syntax = t.syntax; let plain = t.text; @@ -638,26 +694,58 @@ pub fn code_field(ui: &mut Ui, id: egui::Id, text: &mut String, options: &FieldO ui.fonts_mut(|f| f.layout_job(job)) }; let filter = egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: options.keep_escape }; + let gutter = if options.gutter { gutter_width(ui, line_count(text)) } else { 0.0 }; + let max_height = options.max_height.max(1.0); 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(options.width) - .desired_rows(1) - .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); - } - out + egui::ScrollArea::vertical().id_salt(id.with("scroll")).max_height(max_height).auto_shrink([false, !options.fill]).show(ui, |ui| { + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + let gutter_left = ui.cursor().left(); + if gutter > 0.0 { + ui.add_space(gutter); + } + let mut edit = TextEdit::multiline(text) + .id(id) + .font(font.clone()) + .code_editor() + .frame(egui::Frame::NONE) + .desired_width((options.width - gutter).max(1.0)) + .desired_rows(1) + .interactive(options.interactive) + .event_filter(filter) + .layouter(&mut layouter); + if options.fill { + edit = edit.min_size(egui::vec2(0.0, max_height)); + } + let out = edit.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); + } + if gutter > 0.0 { + let right = gutter_left + gutter - GUTTER_GAP; + let rule = egui::Stroke::new(1.0, t.separator); + let clip = ui.clip_rect(); + ui.painter().vline(right + GUTTER_GAP / 2.0, clip.y_range(), rule); + paint_line_numbers(ui.painter(), &out.galley, out.galley_pos, clip, right, &font, t.text_dim); + } + out + }) + .inner }); let out = scrolled.inner; + // The text field's own edits go through the same check as the editor's. + if let (Some(accept), Some((before, sel))) = (options.accept, field_start) + && *text != before + && !accept(text, &before) + { + *text = before; + let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + state.cursor.set_char_range(Some(CCursorRange::two(CCursor::new(sel.0), CCursor::new(sel.1)))); + state.store(ui.ctx(), id); + refused = true; + ui.ctx().request_repaint(); + } // 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); @@ -670,7 +758,57 @@ pub fn code_field(ui: &mut Ui, id: egui::Id, text: &mut String, options: &FieldO .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 } + FieldOutput { response, stop, lines: out.galley.rows.len().max(1), caret, selection, gutter, refused } +} + +/// Space between the line numbers and the code, with the gutter's rule in the middle. +const GUTTER_GAP: f32 = 10.0; + +/// Lines of `text`: one more than its line breaks. +pub fn line_count(text: &str) -> usize { + text.bytes().filter(|b| *b == b'\n').count().saturating_add(1) +} + +/// Digits in the number of the last of `lines` lines (at least one). +pub fn gutter_digits(lines: usize) -> usize { + lines.max(1).checked_ilog10().map_or(1, |d| d as usize + 1) +} + +/// The width of a line-number gutter for `lines` lines: as many digits of the code font as the +/// last line's number has, a margin, and the gap before the code. It grows when the number of +/// lines gains a digit (10, 100, 1000). +pub fn gutter_width(ui: &Ui, lines: usize) -> f32 { + let digit = ui.fonts_mut(|f| f.glyph_width(&font(), '0')); + digit * gutter_digits(lines) as f32 + 6.0 + GUTTER_GAP +} + +/// Paints line numbers right-aligned at `right`, beside `galley` laid out at `galley_pos`: one per +/// line of the text, on the first row of a wrapped line. Only rows inside `clip` are painted, so +/// the work for long code is the rows in view (and a count of the line breaks above them). +pub fn paint_line_numbers( + painter: &egui::Painter, + galley: &egui::Galley, + galley_pos: egui::Pos2, + clip: egui::Rect, + right: f32, + font: &FontId, + color: egui::Color32, +) { + // Rows are in order from the top: skip the ones above the view without laying anything out. + let first = galley.rows.partition_point(|row| galley_pos.y + row.rect().max.y < clip.top()); + let mut line = galley.rows.iter().take(first).filter(|row| row.ends_with_newline).count().saturating_add(1); + let mut starts_line = first.checked_sub(1).and_then(|i| galley.rows.get(i)).is_none_or(|row| row.ends_with_newline); + for row in galley.rows.iter().skip(first) { + let rect = row.rect().translate(galley_pos.to_vec2()); + if rect.top() > clip.bottom() { + break; + } + if starts_line { + painter.text(egui::pos2(right, rect.top()), egui::Align2::RIGHT_TOP, line.to_string(), font.clone(), color); + line = line.saturating_add(1); + } + starts_line = row.ends_with_newline; + } } #[cfg(test)] @@ -959,6 +1097,57 @@ mod tests { assert!(out.held.is_empty()); } + #[test] + fn a_refused_edit_is_undone_on_its_own_and_the_pass_goes_on() { + // At most five characters: each edit is checked by itself, in order. + let five = |new: &str, _old: &str| new.chars().count() <= 5; + let mut events = vec![ + egui::Event::Text("ab".into()), + egui::Event::Paste("cdef".into()), + egui::Event::Text("é".into()), + key(Key::Backspace, Modifiers::NONE), + egui::Event::Text("字字".into()), + ]; + let (mut s, mut sel) = (String::new(), (0, 0)); + let out = apply_in_order_within(&mut events, &mut s, &mut sel, Lang::Python, &|_| false, Some(&five)); + // "ab", the paste refused whole, "é", Backspace, then "字字". + assert_eq!(s, "ab字字"); + assert_eq!(sel, (4, 4), "the selection is a char index after the last accepted edit"); + assert!(out.refused && out.edited); + // An edit that shortens text already past the limit is taken. + let mut s = "abcdefg".to_string(); + let mut sel = (7, 7); + let shrink = |new: &str, old: &str| new.chars().count() <= 5 || new.len() <= old.len(); + let mut events = vec![key(Key::Backspace, Modifiers::NONE), egui::Event::Text("x".into())]; + let out = apply_in_order_within(&mut events, &mut s, &mut sel, Lang::Python, &|_| false, Some(&shrink)); + assert_eq!(s, "abcdef", "Backspace is taken, the letter after it is not"); + assert!(out.refused && out.edited); + // Without a check nothing is refused. + let mut events = vec![egui::Event::Text("y".repeat(50))]; + let out = apply_in_order_until(&mut events, &mut s, &mut sel, Lang::Python, &|_| false); + assert!(!out.refused && s.len() == 56); + } + + #[test] + fn pasted_line_breaks_become_newlines() { + let mut events = vec![egui::Event::Paste("a\r\nb\rc\n".into())]; + let (mut s, mut sel) = ("é".to_string(), (1, 1)); + apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert_eq!(s, "éa\nb\nc\n"); + assert_eq!(sel, (7, 7)); + } + + #[test] + fn the_gutter_counts_lines_and_digits() { + assert_eq!(line_count(""), 1); + assert_eq!(line_count("a\nb"), 2); + assert_eq!(line_count("a\n"), 2, "a line break starts a line, as the editor shows it"); + assert_eq!(line_count("é\n字\n\u{1F600}"), 3); + for (lines, digits) in [(0, 1), (1, 1), (9, 1), (10, 2), (99, 2), (100, 3), (999, 3), (1000, 4), (usize::MAX, 20)] { + assert_eq!(gutter_digits(lines), digits, "{lines}"); + } + } + #[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); From 5f219a5972a6b3f9043afc95bf77e9801221428b Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:48:48 -0400 Subject: [PATCH 2/4] UI: Python editor window for Python cells; ui.pythonEditor The formula bar shows at most five lines of code. The Python editor is a resizable window for one Python cell's code, with line numbers and Python colours. It opens from a button beside the formula bar's PY badge (shown in Python mode and on a selected Python cell), from "Edit in Python Editor" at the top of a Python cell's menu, and from the UI command ui.pythonEditor {on?, cell?, force?}. Opened from Python mode it takes the code typed so far, with its caret, and the formula bar's edit ends. It shows the cell's address and its return type (Python Object or Excel Value) above the code, and the keys, the code's length counted as a PY formula, Apply and Close below it. The window keeps its own copy of the code. Enter and Tab work as in Python mode. Ctrl+Enter (Cmd+Enter on macOS) or Apply puts the code in the cell through python.set, in the window's own workbook even when another is active, and the window stays open; each apply is one undo step. When python.set refuses (a table header, code that doesn't fit) the code stays as typed and the command's message shows under it, in the window. While the window has no changes of its own it follows the cell's code (an undo, a commit from the formula bar); when it takes in the code of a Python cell or an empty cell that way, it shows exactly what the cell holds, so Apply's reference (below) follows the cell too, and typing after an undo of Apply applies to the same cell. It follows its sheet when sheets move or are renamed, and never takes another sheet for a deleted one: Apply is then refused for want of the sheet, and an undo of the deletion brings it back. Apply writes only to the cell the window opened on. The window keeps what the cell held when it opened it or last applied (a Python cell's code and return type; another cell's formula, value or picture, not its result or format), and Apply, from the button, the keys or the question about unsaved changes, is refused when the cell holds something else: a row or column inserted before it, inserted or deleted cells, a cut and move, a sort or an undo can put another cell at its address, and its code can change elsewhere. The code stays in the window, which says the cell changed (before Apply too, once there are changes) and how to go on. With unsaved changes in the window the reference never moves, even when the code that arrives matches the typing: that typing was written for the cell the window opened on. Opening the editor again on that address starts afresh, after the usual question, and ui.pythonEditor treats it the same way. An edit anywhere else doesn't block Apply. There is one editor at a time. Unsaved code is never discarded without an answer: Esc, Close and the window's X, opening it on another cell (or with other code from the formula bar), File > Close on its workbook, the close button of its workbook's last native window, switching to another workbook and quitting (the app window's close button, Quit or Cmd+Q from the menu bar, the Dock or the system) first ask, in the window, whether to Apply, Discard or Cancel; after Apply or Discard the quit goes on and asks about each unsaved workbook. The restart for an update waits for the code too. Cancel after a workbook switch goes back to the code's workbook; Esc is Cancel. These questions are a state of the window, so nothing programmatic opens a dialog: ui.pythonEditor opens the window without a prompt and refuses to close it, or to open it on another cell, while it has unsaved code, unless force: true discards the code; file.close, window.close on the workbook's last window and app.quit are refused for the same reason. An editor without changes closes with its workbook or sheet, or when another workbook is active. ui.inspect reports the window as pythonEditor. With a native window per workbook window, each native window draws the app with its own workbook active, so the editor is drawn only in its own workbook's window: the one it opened in, or that workbook's most recent one once it closes. Drawing another workbook's window neither closes a clean editor nor counts as a switch that asks about unsaved code, and Esc there doesn't answer the editor's question. A question the editor asks, and an opening on another workbook's cell, bring its window forward; the editor still closes when its workbook does. The code can't grow past what a cell holds: the check python.set makes (PyCode::fits, 8,192 characters as a PY formula) refuses typing and pasting past it as it happens, with a message; code loaded longer than that can still be shortened. Highlighting and the gutter stay cheap at the limit: about 1 ms a frame with 8,052 characters in 195 lines, open and typing, in the test build. The window sits at the right below the formula bar, about 40 lines tall when there is room, and stays inside the app window down to 600x400 (in English and German), also after the app window shrinks. The keyboard goes to the code once the window is drawn, and back to it after its buttons are clicked. The button's icon is drawn in code: a window with a column of line numbers beside lines of code. --- crates/ui-egui/examples/snapshot.rs | 3 +- crates/ui-egui/src/control.rs | 18 +- crates/ui-egui/src/formula_bar.rs | 22 + crates/ui-egui/src/icons.rs | 15 + crates/ui-egui/src/lib.rs | 35 +- crates/ui-egui/src/python_card.rs | 9 +- crates/ui-egui/src/python_window.rs | 1089 ++++++++++++++++++++++ crates/ui-egui/src/updates_ui.rs | 2 +- crates/ui-egui/tests/doc_windows.rs | 118 +++ crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/python_editor.rs | 1038 +++++++++++++++++++++ 11 files changed, 2337 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/src/python_window.rs create mode 100644 crates/ui-egui/tests/it/python_editor.rs diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 494d053c9d..1e0065ae6f 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -5,7 +5,8 @@ //! [--dialog 'name={json}'] [--hf-edit header|footer] [--ui 'id={json}']... out.png` //! //! `--cmd` runs an engine command on the session before the app starts; `--ui` runs a command -//! through the app (UI commands too, such as `ui.pythonMode` or `ui.pythonCard`) after it starts. +//! through the app (UI commands too, such as `ui.pythonMode`, `ui.pythonCard` or `ui.pythonEditor`) +//! after it starts. use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 6f117b6b45..89ed25b498 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -449,6 +449,7 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json { "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(), "python": e.python})), "dialog": app.dialog.as_ref().map(|d| json!({"name": d.name, "title": d.title, "values": d.values, "tab": d.tab})), + "pythonEditor": crate::python_window::state(app), "backstage": app.backstage.as_ref().map(|b| b.page.id()), "message": app.message.as_ref().map(|(t, m)| json!({"title": t, "text": m})), "selectedChart": app.selected_chart, @@ -846,12 +847,17 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - } } "app.save" => wrap(app.run("file.save", json!({"path": s("path")}))), - "app.quit" => { - // Programmatic calls never open dialogs: quitting from here doesn't ask about unsaved work. - app.allow_close = true; - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - ok(Json::Null) - } + // Programmatic calls never open dialogs: quitting from here doesn't ask about unsaved + // workbooks. Unsaved code in the Python editor refuses the quit instead, as it refuses + // `file.close` (the code is in no workbook, so nothing could save it). + "app.quit" => match crate::python_window::quit_refused(app) { + Some(e) => err(e), + None => { + app.allow_close = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + ok(Json::Null) + } + }, // Anything else that names a command runs it. other if gridcraft_engine::find_command(other).is_some() => wrap(direct(app, other, p, true)), other => err(format!("unknown method `{other}`")), diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 7641817c53..f9c6a9ff23 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -105,6 +105,9 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { if let Some(badge) = python_badge(app, ui, &t) { layout.buttons = layout.buttons.union(badge); } + if let Some(button) = python_editor_button(app, ui, &t) { + layout.buttons = layout.buttons.union(button); + } ui.add_space(4.0); // The formula field fills the rest, as tall as the bar. let avail = ui.available_width() - 24.0; @@ -276,6 +279,25 @@ fn python_badge(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) -> Option Option { + let show = match &app.editor { + Some(ed) => ed.python, + None => active_python(app).is_some(), + }; + // Not on code hidden on a protected sheet: the window would show it. + if !show || !crate::python_window::bar_offers(app) { + return None; + } + let label = app.ui.language.tr(crate::python_window::EDIT_IN_EDITOR).to_string(); + let resp = crate::widgets::icon_button(ui, Icon::PythonEditor, t.text, &label, vec2(24.0, 24.0)); + if resp.clicked() { + crate::python_window::open_from_bar(app); + } + Some(resp.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"); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 763da67b9e..e5ccdb762a 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -137,6 +137,8 @@ pub enum Icon { Print, /// Python in cells: a cell with a green PY tag over its corner (original; no language logo). Python, + /// The Python editor: a window with a column of line numbers beside lines of code (original). + PythonEditor, } struct G<'a> { @@ -769,6 +771,19 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { g.p.rect_filled(Rect::from_min_max(g.pt(4.5, 8.5), g.pt(15.0, 15.0)), 1.6 * g.s, green); g.text(9.75, 11.85, "PY", 6.2, paper, true, false); } + Icon::PythonEditor => { + // A window: its title bar, a gutter of line numbers (dots) and lines of code, the + // second one indented. + g.rect(1.5, 2.0, 13.0, 12.0, Some(paper), Some(c)); + g.line(&[(1.5, 4.8), (14.5, 4.8)], c, 1.1); + g.line(&[(5.0, 4.8), (5.0, 14.0)], light, 1.0); + for y in [7.0, 9.4, 11.8] { + g.circle(3.3, y, 0.55, Some(c), None); + } + g.line(&[(6.6, 7.0), (11.0, 7.0)], green, 1.2); + g.line(&[(8.2, 9.4), (13.0, 9.4)], c, 1.1); + g.line(&[(6.6, 11.8), (10.0, 11.8)], c, 1.1); + } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a0e6b239f7..db230baf9b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,6 +33,7 @@ pub mod paper_view; pub mod pivot_pane; pub mod printing; pub mod python_card; +pub mod python_window; pub mod ribbon; pub mod selection_glide; pub mod tabs; @@ -289,6 +290,8 @@ pub struct SheetApp { pub views: HashMap<(u64, usize), SheetView>, pub editor: Option, pub text_box_editor: Option, + /// The Python editor window, when open (one at a time, for one cell of one workbook). + pub py_window: Option, pub dialog: Option, /// The File tab's Backstage view, when open (UI-only; replaces the ribbon, grid and tabs). pub backstage: Option, @@ -392,6 +395,7 @@ impl SheetApp { views: HashMap::new(), editor: None, text_box_editor: None, + py_window: None, dialog: None, backstage: None, message: None, @@ -509,6 +513,11 @@ impl SheetApp { } fn run_command(&mut self, id: &str, params: Json) -> Result { + // Closing the workbook would lose the Python editor's unsaved code. The interface asks + // first (see `run_or_alert`); a programmatic call is refused. + if (id == "file.close" && python_window::blocks_close(self)) || (id == "window.close" && python_window::blocks_window_close(self, ¶ms)) { + return Err("The Python editor has unsaved changes for this workbook. Apply or discard them first (ui.pythonEditor with on: false and force: true discards them).".into()); + } let switches_window = id.starts_with("window.") || matches!(id, "view.newWindow" | "view.hideWindow" | "view.unhideWindow") || (id == "view.switchWindows" && ["window", "doc"].iter().any(|k| params.get(*k).is_some_and(|d| !d.is_null()))); @@ -665,6 +674,14 @@ impl SheetApp { } pub fn run_or_alert(&mut self, id: &str, params: Json) { + // File › Close (or Ctrl+W) with unsaved code in the Python editor: the editor asks first. + if id == "file.close" && python_window::blocks_close(self) { + return python_window::ask(self, python_window::Pending::CloseWorkbook(params)); + } + // The close button of the workbook's last window (a native window) asks the same way. + if id == "window.close" && python_window::blocks_window_close(self, ¶ms) { + return python_window::ask(self, python_window::Pending::CloseWindow(params)); + } // A menu/shortcut opens the settings first; the Print page's button submits explicitly. if id == "file.print" { if self.services.print_native.is_some() { @@ -872,6 +889,7 @@ impl SheetApp { Ok(json!({"on": self.editor.as_ref().is_some_and(|e| e.python)})) } "ui.pythonCard" => python_card::command(self, p), + "ui.pythonEditor" => python_window::command(self, p), "ui.message.dismiss" => { self.message = None; Ok(Json::Null) @@ -1466,6 +1484,11 @@ impl SheetApp { if self.native_windows || self.allow_close || !ctx.input(|i| i.viewport().close_requested()) { return; } + // Unsaved code in the Python editor: the editor asks first, and the quit goes on after. + if python_window::ask_before_quit(self) { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + return; + } // A cell still being edited is part of the workbook, as for File › Close. if !self.commit_edit(0, 0, false, false) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); @@ -1532,10 +1555,14 @@ impl SheetApp { true } - /// Quit (the macOS app menu, ⌘Q): commits every window's cell draft as Enter would, then asks - /// about each unsaved workbook in its window; Cancel on any of them stops the quit. A draft its - /// workbook refuses stops the quit too, with its window in front and the reason shown. + /// Quit (the macOS app menu, ⌘Q): asks about unsaved code in the Python editor first, then + /// commits every window's cell draft as Enter would, then asks about each unsaved workbook in + /// its window; Cancel on any of them stops the quit. A draft its workbook refuses stops the + /// quit too, with its window in front and the reason shown. pub fn request_quit(&mut self, ctx: &egui::Context) { + if python_window::ask_before_quit(self) { + return; + } while let Some(window) = self.parked_window_with_draft() { self.enter_window(window); if self.settle_drafts().is_err() { @@ -1856,6 +1883,7 @@ impl SheetApp { if self.draft_elsewhere() { self.commit_edit(0, 0, false, false); } + python_window::before_ui(self, &ctx); // The language the widgets translate with this frame (see `i18n::current`). i18n::set_current(&ctx, self.ui.language); #[cfg(not(target_arch = "wasm32"))] @@ -1895,6 +1923,7 @@ impl SheetApp { self.perf.grid_ms = now_ms() - g0; }); } + python_window::show(self, &ctx); dialogs::show(self, &ctx); widgets::message_box(self, &ctx); widgets::toast(self, &ctx); diff --git a/crates/ui-egui/src/python_card.rs b/crates/ui-egui/src/python_card.rs index c671a8b692..9c0b5d0e80 100644 --- a/crates/ui-egui/src/python_card.rs +++ b/crates/ui-egui/src/python_card.rs @@ -206,11 +206,16 @@ fn popup_dismissed(response: &egui::Response, was_open: bool) -> bool { response.ctx.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)) || (was_open && response.clicked_elsewhere()) } -/// The cell menu's return-type items on a Python cell: "Python Object" and "Excel Value", the -/// current one marked. Returns whether one was picked (the menu then closes). +/// The cell menu's items on a Python cell: "Edit in Python Editor", then the return types, +/// "Python Object" and "Excel Value", the current one marked. Returns whether one was picked (the +/// menu then closes). pub fn menu_items(app: &mut SheetApp, ui: &mut egui::Ui) -> bool { let Some((_, sheet, cell, py)) = active_python_cell(app) else { return false }; let lang = app.ui.language; + if crate::python_window::menu_offers(app) && ui.button(lang.tr(crate::python_window::EDIT_IN_EDITOR)).clicked() { + crate::python_window::open_from_menu(app); + return true; + } let mut picked = None; for (label, object) in [(PYTHON_OBJECT, true), (EXCEL_VALUE, false)] { if ui.radio(py.object == object, lang.tr(label)).clicked() { diff --git a/crates/ui-egui/src/python_window.rs b/crates/ui-egui/src/python_window.rs new file mode 100644 index 0000000000..4d47c282a0 --- /dev/null +++ b/crates/ui-egui/src/python_window.rs @@ -0,0 +1,1089 @@ +//! The Python editor: a resizable window for writing a Python cell's code, with line numbers and +//! Python colours. It opens from the formula bar's editor button (shown in Python mode and on a +//! selected Python cell), from "Edit in Python Editor" in a Python cell's menu, and from +//! `ui.pythonEditor`. +//! +//! The window keeps its own copy of the code. Enter and Tab work as in the formula bar's Python +//! mode; Ctrl+Enter (Cmd+Enter on macOS) or Apply puts the code in the cell through `python.set`, +//! and the window stays open; Esc or Close closes it. There is one editor at a time, and it belongs +//! to one cell of one workbook. Nothing loses its code without an answer: closing it, opening it +//! on another cell, closing its workbook, switching to another workbook or quitting first asks +//! whether to apply or discard unsaved changes, in the window. Programmatic calls never ask: they +//! refuse while there are unsaved changes, unless told to discard them (`force`). +//! +//! Apply writes only to the cell the window opened on: when the cell no longer holds what it held +//! when the window opened it or last applied (a row or column inserted before it moved another +//! cell to its address, or its code changed from elsewhere), Apply is refused and the code stays in +//! the window, rather than replacing another cell's content. +//! +//! The code can't grow past what a cell holds (`PyCode::fits`, the limit `python.set` checks): +//! typing or pasting past it is refused, with a message, as it happens. +//! +//! With a native window per workbook window (`SheetApp::native_windows`), every native window draws +//! the app in turn, each with its own workbook active. The editor is drawn only in its own +//! workbook's window (the one it opened in, or that workbook's most recent one), so drawing another +//! workbook's window neither closes it nor counts as a switch to that workbook; a question it asks, +//! or an opening on another workbook's cell, brings its window forward. + +use std::sync::Arc; + +use egui::{Align, Id, Layout, Rect, RichText, Ui, pos2, vec2}; +use gridcraft_engine::core::{CellRef, RangeRef, Value, quote_sheet, split_sheet}; +use gridcraft_engine::model::python::{MAX_FORMULA_CHARS, normalize_code}; +use gridcraft_engine::model::{CellPicture, PyCode, Sheet}; +use serde_json::{Value as Json, json}; + +use crate::code_editor::{self, FieldOptions, Lang}; +use crate::python_card::{EXCEL_VALUE, PYTHON_OBJECT}; +use crate::theme::{self, Tokens}; +use crate::{SheetApp, clean_error, formula_bar}; + +/// The window's title. +pub const TITLE: &str = "Python Editor"; +/// The formula bar button's and the cell menu's label. +pub const EDIT_IN_EDITOR: &str = "Edit in Python Editor"; +/// Shown in the window while the code differs from the cell's. +pub const UNSAVED: &str = "Unsaved changes"; +/// The question asked before unsaved code would be lost. +pub const ASK: &str = "The code for {cell} has unsaved changes."; +/// The same, once another workbook is active. +pub const ASK_IN: &str = "The code for {cell} in {workbook} has unsaved changes."; +/// Typing or pasting past the limit. +pub const TOO_LONG: &str = "The code can't be longer: a Python cell holds {max} characters, counted as a PY formula."; +/// The cell no longer holds what it held when the window opened it or last applied (another cell +/// moved to its address, or its code changed elsewhere) while the window has unsaved changes. +pub const CHANGED: &str = "{cell} changed while it was open in the Python editor. Copy the code, or close the editor and open it again on the cell."; +/// Apply refused for the same reason. +pub const STALE: &str = "{cell} changed while it was open in the Python editor, so the code was not applied. Copy the code, or close the editor and open it again on the cell."; +/// The keys, under the code. +pub const HINT: &str = "{apply} applies the code. Esc closes the editor."; +/// The window's code went with a workbook that closed some other way (the last resort). +pub const COPIED: &str = "The Python editor's unsaved code was copied to the clipboard."; +pub const APPLY: &str = "Apply"; +pub const DISCARD: &str = "Discard"; +pub const CANCEL: &str = "Cancel"; +pub const CLOSE: &str = "Close"; + +/// Lines of code the window shows when it opens, room permitting. +pub const LINES: usize = 40; +/// The window's distance from the app window's edges. +const MARGIN: f32 = 8.0; +/// The window's frame and title bar around its contents (egui's window margins, the title row). +const FRAME: egui::Vec2 = egui::Vec2::new(20.0, 52.0); +/// The header and footer of the contents, around the code. +const CHROME_HEIGHT: f32 = 92.0; + +/// The cell the editor edits: its workbook (by uid), sheet and address. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Target { + pub doc: u64, + pub sheet: usize, + pub cell: CellRef, +} + +/// What the window asks about before unsaved code would be lost, and what happens after. +#[derive(Clone, Debug, PartialEq)] +pub enum Pending { + /// Esc, Close or the window's X: the window closes. + Close, + /// The editor was asked for another cell (or with other code, from the formula bar): it opens + /// there. + Open { target: Target, from_bar: bool }, + /// Another workbook is active: the window closes, or Cancel goes back to its workbook. + Switched, + /// File › Close on the window's workbook: the workbook closes (with `file.close` and these + /// params, which ask about the workbook's own changes). + CloseWorkbook(Json), + /// The close button of the last window on the window's workbook: the window closes (with + /// `window.close` and these params, which ask about the workbook's own changes). + CloseWindow(Json), + /// Quitting (the app window's close button, Quit or ⌘Q, the Dock or the system): the quit goes + /// on, asking about each unsaved workbook. + Quit, +} + +/// An answer to [`Pending`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Answer { + Apply, + Discard, + Cancel, +} + +/// A message under the code. +#[derive(Clone, Debug, PartialEq)] +pub enum Notice { + /// An edit was refused: the code would not fit in a cell. + TooLong, + /// `python.set` refused the code; its message. + Refused(String), + /// Apply was refused: the cell no longer holds what it held (see [`Held`]). + Stale, +} + +/// What a cell holds, as Apply checks it: a Python cell's code and return type, or another cell's +/// formula, constant value or picture. Not its result or its format, so a recalculation or a new +/// format is not a change. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Held { + python: Option<(String, bool)>, + formula: Option<(String, Option)>, + value: Value, + picture: Option>, +} + +impl Held { + fn of(sh: &Sheet, c: CellRef) -> Held { + let picture = sh.cell_pictures.get(&c).cloned(); + match sh.cell(c) { + Some(cell) => match (&cell.py, &cell.formula) { + (Some(py), _) => Held { python: Some((py.code.clone(), py.object)), picture, ..Held::default() }, + (None, Some(f)) => Held { formula: Some((f.text.clone(), f.array)), picture, ..Held::default() }, + (None, None) => Held { value: cell.value.clone(), picture, ..Held::default() }, + }, + None => Held { picture, ..Held::default() }, + } + } +} + +/// The open editor. +#[derive(Clone, Debug)] +pub struct PyWindow { + pub doc: u64, + /// The engine window it opened in, one of its workbook's: with native windows, it is drawn + /// there (or, once that window closes, in its workbook's most recent one). + pub window: Option, + pub sheet: usize, + /// The sheet's name, to follow it when sheets move. + pub sheet_name: String, + /// This opening of the window: the code field's id, so its caret, focus and undo history stay + /// with it when its sheet moves or is renamed, and a new opening starts afresh. + serial: u64, + /// How many sheets the workbook had while the window last saw its sheet: a sheet whose name is + /// gone was renamed when the count is the same, deleted when it is not. + sheets: usize, + /// The sheet was deleted: only a sheet of its name (an undo) brings it back. + lost: bool, + pub cell: CellRef, + /// The code being edited. + pub text: String, + /// The cell's code when the window loaded it or last applied it. + pub saved: String, + /// What the cell held when the window opened it or last applied: Apply writes only while it + /// still holds that. + held: Held, + pub notice: Option, + pub pending: Option, + /// Put the keyboard in the code on the next frame. + pub focus: bool, + /// Put the caret here (a char index) on the next frame. + pub caret: Option, + /// The code's length counted as a PY formula, worked out when the code changes. + count: Option, + /// The line-number gutter's width, last frame. + pub gutter: f32, + /// The window on screen, last frame. + pub rect: Option, +} + +impl PyWindow { + pub fn target(&self) -> Target { + Target { doc: self.doc, sheet: self.sheet, cell: self.cell } + } + + /// Whether the code differs from the cell's code as last loaded or applied. + pub fn dirty(&self) -> bool { + self.text != self.saved && normalize_code(&self.text) != self.saved + } + + /// The cell's address, with its sheet: `Sheet1!A1`. + pub fn address(&self) -> String { + format!("{}!{}", quote_sheet(&self.sheet_name), self.cell.a1()) + } + + fn set_text(&mut self, text: String) { + self.text = text; + self.count = None; + } +} + +/// Whether `code` fits in a cell, as `python.set` checks it. The work is bounded by the limit, +/// not by the length of `code`. +pub fn fits(code: &str) -> bool { + code.chars().nth(MAX_FORMULA_CHARS).is_none() && PyCode::new(code).fits() +} + +/// `code`'s length counted as a PY formula, `PY("…",0)` (the measure of [`PyCode::fits`]). +pub fn formula_len(code: &str) -> usize { + PyCode::new(code).to_file_formula().chars().count() +} + +/// Whether the editor takes an edit from `old` to `new`: the new code fits in a cell, or (for code +/// that came in longer than the limit) the edit doesn't make it longer. +pub fn accept_code(new: &str, old: &str) -> bool { + fits(new) || (new.len() <= old.len() && formula_len(new) <= formula_len(old)) +} + +/// The window, for egui (its position and size are kept under this id). +pub fn window_id() -> Id { + Id::unique("gridcraft.python_editor.window") +} + +/// The code field's id: one per opening of the window (see `PyWindow::serial`). +pub fn editor_id(w: &PyWindow) -> Id { + Id::unique("gridcraft.python_editor").with(w.serial) +} + +/// Numbers the openings of the window. +static OPENINGS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +fn doc_index(app: &SheetApp, uid: u64) -> Option { + app.session.documents().iter().position(|d| d.uid == uid) +} + +/// The Python code of a cell (empty for a cell without code), and whether it is a Python object. +fn cell_code(app: &SheetApp, t: Target) -> (String, bool) { + let py = app + .session + .documents() + .iter() + .find(|d| d.uid == t.doc) + .and_then(|d| d.wb.sheet(t.sheet)) + .and_then(|sh| sh.cell(t.cell)) + .and_then(|c| c.py.clone()); + py.map(|p: Arc| (p.code.clone(), p.object)).unwrap_or_default() +} + +/// What cell `t` holds, while its workbook and sheet are there. +fn held(app: &SheetApp, t: Target) -> Option { + let d = app.session.documents().iter().find(|d| d.uid == t.doc)?; + d.wb.sheet(t.sheet).map(|sh| Held::of(sh, t.cell)) +} + +/// The window's cell's code and return type, while its sheet is there (see `sync`). +fn window_cell(app: &SheetApp, w: &PyWindow) -> Option<(String, bool)> { + let d = app.session.documents().iter().find(|d| d.uid == w.doc)?; + d.wb.sheet(w.sheet).filter(|sh| sh.name == w.sheet_name)?; + Some(cell_code(app, w.target())) +} + +/// Whether the window's cell, while its sheet is there, no longer holds what it held when the +/// window opened it or last applied: another cell moved to its address, or its content changed. +fn stale(app: &SheetApp, w: &PyWindow) -> bool { + let d = app.session.documents().iter().find(|d| d.uid == w.doc); + let sheet = d.and_then(|d| d.wb.sheet(w.sheet)).filter(|sh| sh.name == w.sheet_name); + sheet.is_some_and(|sh| Held::of(sh, w.cell) != w.held) +} + +/// Whether the code of `t` is out of view: a Hidden cell, or the cell whose result spills over +/// it, on a protected sheet. The editor never shows such code. +fn target_hidden(app: &SheetApp, t: Target) -> bool { + let d = app.session.documents().iter().find(|d| d.uid == t.doc); + d.and_then(|d| d.wb.sheet(t.sheet).map(|sh| gridcraft_engine::display::formula_hidden(&d.wb, sh, t.cell))).unwrap_or(false) +} + +/// The message for a cell whose code is hidden. +fn hidden_error(cell: CellRef) -> String { + format!("The contents of {} are hidden because the sheet is protected, so the Python editor can't open them.", cell.a1()) +} + +/// Whether the editor would open on `cell` of sheet `sheet` of the active workbook: not on code +/// hidden on a protected sheet. The formula bar's button and the cell menu offer it only then. +pub fn can_open(app: &SheetApp, sheet: usize, cell: CellRef) -> bool { + let Some(d) = app.session.active() else { return false }; + let Some(sh) = d.wb.sheet(sheet) else { return false }; + let at = sh.merge_at(cell).map(|m| m.start).unwrap_or(cell); + !gridcraft_engine::display::formula_hidden(&d.wb, sh, at) +} + +/// The cell the editor opens on for `cell` on sheet `sheet` of the active workbook: the top left +/// of a merged area, or the Python cell whose result spills over it. Refused when its code is +/// hidden on a protected sheet. +pub fn target(app: &SheetApp, sheet: usize, cell: CellRef) -> Result { + let d = app.session.active().ok_or("No workbook is open.")?; + let sh = d.wb.sheet(sheet).ok_or("No such sheet.")?; + let at = sh.merge_at(cell).map(|m| m.start).unwrap_or(cell); + if gridcraft_engine::display::formula_hidden(&d.wb, sh, at) { + return Err(hidden_error(at)); + } + let at = match sh.spill_anchor(at) { + Some(anchor) if sh.cell(anchor).is_some_and(|c| c.py.is_some()) => anchor, + Some(anchor) => return Err(format!("{} is part of the result spilled from {}; it can't hold Python code.", at.a1(), anchor.a1())), + None => at, + }; + Ok(Target { doc: d.uid, sheet, cell: at }) +} + +/// Opens the window on `t`, replacing what it showed: the cell's code, or `text` (with the caret +/// at `caret`) when the code comes from the formula bar. +fn open_fresh(app: &mut SheetApp, t: Target, text: Option<(String, usize)>) { + let (code, _) = cell_code(app, t); + let doc = app.session.documents().iter().find(|d| d.uid == t.doc); + let sheet_name = doc.and_then(|d| d.wb.sheet(t.sheet)).map(|s| s.name.clone()); + let sheets = doc.map_or(0, |d| d.wb.sheets.len()); + let (text, caret) = text.unwrap_or_else(|| (code.clone(), 0)); + let held = held(app, t).unwrap_or_default(); + let active = app.session.active_window(); + let window = active.filter(|w| app.session.window(*w).is_some_and(|x| x.doc_uid == t.doc)).or_else(|| app.session.doc_window(t.doc)); + app.py_window = Some(PyWindow { + doc: t.doc, + window, + sheet: t.sheet, + sheet_name: sheet_name.unwrap_or_default(), + serial: OPENINGS.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + sheets, + lost: false, + cell: t.cell, + text, + saved: code, + held, + notice: None, + pending: None, + focus: true, + caret: Some(caret), + count: None, + gutter: 0.0, + rect: None, + }); + bring_forward(app); +} + +/// The engine window the editor is drawn in: the one it opened in while that window shows its +/// workbook, else its workbook's most recent window. `None` once its workbook is closed. +fn home(app: &SheetApp, w: &PyWindow) -> Option { + w.window.filter(|id| app.session.window(*id).is_some_and(|x| x.doc_uid == w.doc)).or_else(|| app.session.doc_window(w.doc)) +} + +/// Whether the window being drawn is the editor's: always with a single app window; with native +/// windows, only its home window ([`home`]). +fn drawn_here(app: &SheetApp) -> bool { + !app.native_windows || app.py_window.as_ref().is_some_and(|w| home(app, w).is_some() && home(app, w) == app.session.active_window()) +} + +/// With native windows, makes the editor's window the active one, so it comes forward (as a +/// command that activates a window does) and shows what the editor asks or opened. +fn bring_forward(app: &mut SheetApp) { + if !app.native_windows { + return; + } + if let Some(h) = app.py_window.as_ref().and_then(|w| home(app, w)) + && app.session.active_window() != Some(h) + { + app.session.activate_window(h); + } +} + +/// The message for unsaved changes that a programmatic call would discard. +fn unsaved_error(w: &PyWindow) -> String { + format!("The Python editor has unsaved changes to {}. Apply them, or pass force: true to discard them.", w.address()) +} + +/// Opens the editor on `t`. `text` is code (and a caret) the formula bar was editing for `t`, when +/// it differs from the cell's. When the window has unsaved changes that this would discard, it +/// asks first (`ask`, the interface's routes), discards them (`force`), or refuses. +pub fn request_open(app: &mut SheetApp, t: Target, text: Option<(String, usize)>, ask: bool, force: bool) -> Result<(), String> { + if target_hidden(app, t) { + return Err(hidden_error(t.cell)); + } + // A window whose cell changed is not on `t` any more, even at the same address: opening it + // again starts afresh (after asking about its unsaved code). + let moved = app.py_window.as_ref().is_some_and(|w| stale(app, w)); + let Some(w) = app.py_window.as_mut() else { + open_fresh(app, t, text); + return Ok(()); + }; + let same = w.target() == t && !moved; + let conflict = w.dirty() && (!same || text.as_ref().is_some_and(|(code, _)| normalize_code(code) != normalize_code(&w.text))); + if conflict && !force { + if ask { + w.pending = Some(Pending::Open { target: t, from_bar: text.is_some() }); + bring_forward(app); + return Ok(()); + } + return Err(unsaved_error(w)); + } + if same && !conflict { + if let Some((code, caret)) = text { + w.set_text(code); + w.caret = Some(caret); + } + w.focus = true; + w.pending = None; + return Ok(()); + } + open_fresh(app, t, text); + Ok(()) +} + +/// The formula bar's Python edit of `t`, when its code differs from the cell's: its text and caret. +fn bar_code(app: &SheetApp, t: Target) -> Option<(String, usize)> { + let ed = app.editor.as_ref().filter(|e| e.python)?; + let here = app.session.active().map(|d| d.uid) == Some(t.doc) && ed.sheet == t.sheet && ed.cell == t.cell; + let (code, _) = cell_code(app, t); + (here && normalize_code(&ed.text) != code).then(|| (ed.text.clone(), ed.caret)) +} + +/// Ends the formula bar's Python edit of `t`: its code went to the window. +fn end_bar_edit(app: &mut SheetApp, t: Target) { + let here = app.session.active().map(|d| d.uid) == Some(t.doc); + if here && app.editor.as_ref().is_some_and(|e| e.python && e.sheet == t.sheet && e.cell == t.cell) { + app.cancel_edit(); + } +} + +/// The cell the formula bar's editor button is for: the cell being edited in Python mode, or the +/// selected Python cell. +fn bar_cell(app: &SheetApp) -> Option<(usize, CellRef)> { + match app.editor.as_ref() { + Some(ed) if ed.python => Some((ed.sheet, ed.cell)), + _ => crate::python_card::active_python_cell(app).map(|(_, sheet, cell, _)| (sheet, cell)), + } +} + +/// Whether the formula bar offers its editor button: on a cell `open_from_bar` would open. +pub fn bar_offers(app: &SheetApp) -> bool { + bar_cell(app).is_some_and(|(sheet, cell)| can_open(app, sheet, cell)) +} + +/// Whether the cell menu offers "Edit in Python Editor": on a selected Python cell whose code is +/// not hidden. +pub fn menu_offers(app: &SheetApp) -> bool { + crate::python_card::active_python_cell(app).is_some_and(|(_, sheet, cell, _)| can_open(app, sheet, cell)) +} + +/// The formula bar's editor button: opens the editor on the cell being edited in Python mode, +/// with its code as typed so far, or on the selected Python cell. The formula bar's edit moves to +/// the window. Nothing on code hidden on a protected sheet. +pub fn open_from_bar(app: &mut SheetApp) { + if let Some((sheet, cell)) = bar_cell(app).filter(|&(sheet, cell)| can_open(app, sheet, cell)) { + open_from_ui(app, sheet, cell, true); + } +} + +/// The cell menu's "Edit in Python Editor", on the selected Python cell. Nothing on code hidden on +/// a protected sheet. +pub fn open_from_menu(app: &mut SheetApp) { + if let Some((_, sheet, cell, _)) = crate::python_card::active_python_cell(app).filter(|(_, sheet, cell, _)| can_open(app, *sheet, *cell)) { + open_from_ui(app, sheet, cell, false); + } +} + +fn open_from_ui(app: &mut SheetApp, sheet: usize, cell: CellRef, from_bar: bool) { + let t = match target(app, sheet, cell) { + Ok(t) => t, + Err(e) => { + app.message = Some(("GridCraft".into(), e)); + return; + } + }; + let text = if from_bar { bar_code(app, t) } else { None }; + // An open window that asks first keeps the formula bar's edit until it is answered. + if request_open(app, t, text, true, false).is_ok() && app.py_window.as_ref().is_some_and(|w| w.pending.is_none()) && from_bar { + end_bar_edit(app, t); + } +} + +/// Closes the editor. With unsaved changes it asks first (`ask`), discards them (`force`), or +/// refuses. +pub fn request_close(app: &mut SheetApp, ask: bool, force: bool) -> Result<(), String> { + let Some(w) = app.py_window.as_mut() else { return Ok(()) }; + if w.dirty() && !force { + if ask { + w.pending = Some(Pending::Close); + bring_forward(app); + return Ok(()); + } + return Err(unsaved_error(w)); + } + app.py_window = None; + Ok(()) +} + +/// Asks about unsaved changes before `p`. +pub fn ask(app: &mut SheetApp, p: Pending) { + if let Some(w) = app.py_window.as_mut() { + w.pending = Some(p); + } + // The File view covers the sheet and the window: close it so the question is in sight. + app.backstage = None; + bring_forward(app); +} + +/// Whether closing the active workbook would lose the editor's unsaved code. +pub fn blocks_close(app: &SheetApp) -> bool { + let active = app.session.active().map(|d| d.uid); + app.py_window.as_ref().is_some_and(|w| w.dirty() && Some(w.doc) == active) +} + +/// Whether the editor has unsaved changes. +pub fn is_dirty(app: &SheetApp) -> bool { + app.py_window.as_ref().is_some_and(PyWindow::dirty) +} + +/// Whether `window.close` with `params` would close the window's workbook (its last window) and +/// so lose the editor's unsaved code. A window the params don't name is not closed, so it blocks +/// nothing. +pub fn blocks_window_close(app: &SheetApp, params: &Json) -> bool { + let Some(w) = app.py_window.as_ref().filter(|w| w.dirty()) else { return false }; + let Ok(window) = gridcraft_engine::Target::from_json(params).and_then(|t| app.session.target_window(t)) else { return false }; + let doc = app.session.window(window).map(|x| x.doc_uid); + doc == Some(w.doc) && app.session.windows().iter().filter(|x| x.doc_uid == w.doc).count() == 1 +} + +/// Asks about the editor's unsaved code before quitting, in its workbook's window, which comes +/// forward. Answering it goes on with the quit ([`answer`]). Returns whether it asks. +pub fn ask_before_quit(app: &mut SheetApp) -> bool { + let Some(doc) = app.py_window.as_ref().filter(|w| w.dirty()).map(|w| w.doc) else { return false }; + if let Some(window) = app.session.doc_window(doc) { + app.enter_window(window); + } + ask(app, Pending::Quit); + true +} + +/// The refusal of a programmatic quit while the editor has unsaved changes (programmatic calls +/// never ask). +pub fn quit_refused(app: &SheetApp) -> Option { + let w = app.py_window.as_ref().filter(|w| w.dirty())?; + Some(format!( + "The Python editor has unsaved changes to {}. Apply or discard them first (ui.pythonEditor with on: false and force: true discards them).", + w.address() + )) +} + +/// Puts the code in its cell through `python.set`, in the window's workbook (active or not). On a +/// refusal the code stays as typed and the command's message shows under it. A cell that no longer +/// holds what it held when the window opened it or last applied is refused before `python.set`: +/// the code would replace another cell's content. Returns whether the code went in. +pub fn apply(app: &mut SheetApp) -> bool { + let Some(w) = app.py_window.as_ref() else { return false }; + if stale(app, w) { + if let Some(w) = app.py_window.as_mut() { + w.notice = Some(Notice::Stale); + w.focus = true; + } + return false; + } + let params = json!({"sheet": w.sheet_name, "cell": w.cell.a1(), "code": w.text}); + let t = w.target(); + let Some(i) = doc_index(app, t.doc) else { + if let Some(w) = app.py_window.as_mut() { + w.notice = Some(Notice::Refused("The workbook this code belongs to is closed.".into())); + } + return false; + }; + let before = app.session.active_index(); + app.session.set_active(i); + let r = app.session.run("python.set", params); + app.session.set_active(before); + app.after_engine(); + let (code, _) = cell_code(app, t); + let now = held(app, t); + let Some(w) = app.py_window.as_mut() else { return r.is_ok() }; + match r { + Ok(_) => { + w.saved = code; + if let Some(now) = now { + w.held = now; + } + w.notice = None; + true + } + Err(e) => { + w.notice = Some(Notice::Refused(clean_error(&e))); + w.focus = true; + false + } + } +} + +/// Acts on the answer to the window's question. +pub fn answer(app: &mut SheetApp, ctx: &egui::Context, a: Answer) { + let Some(p) = app.py_window.as_ref().and_then(|w| w.pending.clone()) else { return }; + if a == Answer::Cancel { + if let Some(w) = app.py_window.as_mut() { + w.pending = None; + w.focus = true; + } + // Back to the workbook the code belongs to. + if p == Pending::Switched + && let Some(i) = app.py_window.as_ref().and_then(|w| doc_index(app, w.doc)) + { + app.session.set_active(i); + } + return; + } + if a == Answer::Apply && !apply(app) { + // The refusal shows under the code, which is kept. + if let Some(w) = app.py_window.as_mut() { + w.pending = None; + } + return; + } + let doc = app.py_window.as_ref().map(|w| w.doc); + app.py_window = None; + match p { + Pending::Close | Pending::Switched => {} + // The code there was hidden while the question was open: nothing to show. + Pending::Open { target, .. } if target_hidden(app, target) => app.message = Some(("GridCraft".into(), hidden_error(target.cell))), + Pending::Open { target, from_bar } => { + let text = if from_bar { bar_code(app, target) } else { None }; + open_fresh(app, target, text); + if from_bar { + end_bar_edit(app, target); + } + } + Pending::CloseWorkbook(params) => { + if app.session.active().map(|d| d.uid) == doc { + app.run_or_alert("file.close", params); + } + } + // Only while the window is still there, on the code's workbook. + Pending::CloseWindow(params) => { + let window = gridcraft_engine::Target::from_json(¶ms).and_then(|t| app.session.target_window(t)).ok(); + if window.and_then(|x| app.session.window(x)).map(|x| x.doc_uid) == doc { + app.run_or_alert("window.close", params); + } + } + // The quit goes on: each unsaved workbook is asked about next, as Quit does. + Pending::Quit => app.request_quit(ctx), + } +} + +/// `ui.pythonEditor {on?, cell?, force?}`: opens the editor (`on: true`) on `cell` (`A1` or +/// `Sheet2!B3`; default: the active cell), or closes it (`on: false`); without `on` it opens or +/// closes. It never asks: with unsaved changes it refuses to close, or to open on another cell, +/// unless `force: true` discards them. Replies with the editor's state. +pub fn command(app: &mut SheetApp, p: &Json) -> Result { + let force = p.get("force").and_then(Json::as_bool).unwrap_or(false); + let on = match p.get("on") { + None | Some(Json::Null) => app.py_window.is_none(), + Some(Json::Bool(b)) => *b, + Some(_) => return Err("`on` is true or false".into()), + }; + if !on { + request_close(app, false, force)?; + return Ok(state(app)); + } + let d = app.session.active().ok_or("No workbook is open.")?; + let (sheet, cell) = match p.get("cell") { + None | Some(Json::Null) => (d.wb.active_sheet(), d.selection.active), + Some(Json::String(s)) => { + let (sheet, a1) = split_sheet(s.trim()); + let sheet = match sheet { + Some(name) => d.wb.sheet_index(&name).ok_or_else(|| format!("no sheet named {name:?}"))?, + None => d.wb.active_sheet(), + }; + (sheet, CellRef::parse(a1).ok_or_else(|| format!("{s:?} is not a cell"))?) + } + Some(_) => return Err("`cell` is a cell address, such as A1 or Sheet2!B3".into()), + }; + let t = target(app, sheet, cell)?; + let text = bar_code(app, t); + let carried = text.is_some(); + request_open(app, t, text, false, force)?; + if carried || app.editor.as_ref().is_some_and(|e| e.python) { + end_bar_edit(app, t); + } + Ok(state(app)) +} + +/// The editor's state, for `ui.pythonEditor` and `ui.inspect`. +pub fn state(app: &SheetApp) -> Json { + let Some(w) = app.py_window.as_ref() else { return json!({"open": false}) }; + let pending = w.pending.as_ref().map(|p| match p { + Pending::Close => "close", + Pending::Open { .. } => "open", + Pending::Switched => "switched", + Pending::CloseWorkbook(_) => "closeWorkbook", + Pending::CloseWindow(_) => "closeWindow", + Pending::Quit => "quit", + }); + let notice = w.notice.as_ref().map(|n| match n { + Notice::TooLong => TOO_LONG.replace("{max}", &MAX_FORMULA_CHARS.to_string()), + Notice::Refused(m) => m.clone(), + Notice::Stale => STALE.replace("{cell}", &w.address()), + }); + json!({ + "open": true, + "sheet": w.sheet_name, + "cell": w.cell.a1(), + "text": w.text, + "dirty": w.dirty(), + "pending": pending, + "notice": notice, + "rect": w.rect.map(|r| [r.left(), r.top(), r.width(), r.height()]), + }) +} + +/// Closes the window without asking, as when its workbook closed or its code became hidden: its +/// unsaved code goes to the clipboard, with a notice, so it is not lost silently. +fn close_keeping_code(app: &mut SheetApp, ctx: &egui::Context) { + if let Some(w) = app.py_window.take() + && w.dirty() + { + ctx.copy_text(w.text); + app.toast = Some((app.ui.language.tr(COPIED).to_string(), crate::now_ms())); + } +} + +/// Keeps the window with its cell: follows its sheet when sheets move or are renamed, takes in a +/// change of the cell's code when it has no changes of its own, closes when its workbook closes or +/// another is active (asking first when it has changes), and closes when its cell's code becomes +/// hidden on a protected sheet (its unsaved code goes to the clipboard). +fn sync(app: &mut SheetApp, ctx: &egui::Context) { + let Some(w) = app.py_window.as_ref() else { return }; + let Some(d) = app.session.documents().iter().find(|d| d.uid == w.doc) else { + // Its workbook closed without asking (the interface asks first, and programmatic calls + // are refused): the code is not lost silently. + close_keeping_code(app, ctx); + return; + }; + // The sheet: by name when it moved, by place when it was renamed (the count of sheets is the + // same). A deleted sheet is not followed to the sheet that took its place: the window keeps + // its code, and Apply is refused for want of the sheet. + let count = d.wb.sheets.len(); + let found = match d.wb.sheet(w.sheet) { + Some(sh) if sh.name == w.sheet_name => Some((w.sheet, sh.name.clone())), + _ => match d.wb.sheet_index(&w.sheet_name) { + Some(i) => d.wb.sheet(i).map(|sh| (i, sh.name.clone())), + None if count == w.sheets && !w.lost => d.wb.sheet(w.sheet).map(|sh| (w.sheet, sh.name.clone())), + None => None, + }, + }; + // Its sheet was protected with the cell Hidden: the window stops showing the code at once. + // Applying is not offered (the sheet is protected), and asking would keep the code on screen. + if found.as_ref().is_some_and(|(sheet, _)| target_hidden(app, Target { sheet: *sheet, ..w.target() })) { + close_keeping_code(app, ctx); + return; + } + let active = app.session.active().map(|d| d.uid); + let at = found.as_ref().map(|(sheet, _)| Target { sheet: *sheet, ..w.target() }); + let code = at.map(|t| cell_code(app, t).0); + let now = at.and_then(|t| held(app, t)); + let Some(w) = app.py_window.as_mut() else { return }; + w.lost = found.is_none(); + match (found, code) { + (Some((sheet, name)), Some(code)) => { + (w.sheet, w.sheet_name, w.sheets) = (sheet, name, count); + if code != w.saved && (!w.dirty() || normalize_code(&w.text) == code) { + // Nothing of the window's own to keep: it shows the cell's code as it is now. + // + // When Apply's reference (`held`) follows the cell: only here, when the window + // had no unsaved typing and takes in the cell's code (an undo of Apply, a change + // from elsewhere), and only when the cell is a Python cell or empty. The window then + // shows exactly what the cell holds, so Apply edits what the user sees, even when + // another cell moved to the address. With unsaved typing, `held` stays as it was, + // even when the cell's new code matches the typing: that typing was written for the + // cell the window opened on, and a cell that moved in (a row insert, a sort) or + // changed elsewhere must refuse Apply rather than take it. A cell holding a formula, + // a value or a picture is not shown by the window, so it keeps refusing Apply. + if !w.dirty() { + w.set_text(code.clone()); + if let Some(now) = now.filter(|h| h.python.is_some() || *h == Held::default()) { + w.held = now; + } + } + w.saved = code; + } + } + // Its sheet was deleted: with nothing to keep, the window goes with it. + _ if !w.dirty() => { + app.py_window = None; + return; + } + _ => {} + } + if active != Some(w.doc) { + if !w.dirty() { + app.py_window = None; + } else if !matches!(w.pending, Some(Pending::Switched | Pending::Quit)) { + w.pending = Some(Pending::Switched); + } + } else if w.pending == Some(Pending::Switched) { + w.pending = None; + } +} + +/// Before the sheet takes this frame's keys: Esc answers the window's question with Cancel. (The +/// question's buttons give the focus up on Esc, and the sheet would take the key.) +pub fn before_ui(app: &mut SheetApp, ctx: &egui::Context) { + // Esc in another native window is that window's key. + let asking = app.py_window.as_ref().is_some_and(|w| w.pending.is_some()) && drawn_here(app); + if asking + && app.dialog.is_none() + && app.message.is_none() + && app.backstage.is_none() + && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) + { + answer(app, ctx, Answer::Cancel); + } +} + +/// Draws the editor, if it is open. +pub fn show(app: &mut SheetApp, ctx: &egui::Context) { + if !drawn_here(app) { + // Another native window is being drawn, with its own workbook active: the editor isn't + // drawn there, and that workbook being active is no switch. It still closes when its + // workbook is gone (its unsaved code goes to the clipboard). + let gone = app.py_window.as_ref().is_some_and(|w| home(app, w).is_none()); + if gone { + close_keeping_code(app, ctx); + } + return; + } + sync(app, ctx); + if app.py_window.is_none() || app.backstage.is_some() { + return; + } + let t = Tokens::get(ctx); + let title = app.ui.language.tr(TITLE).to_string(); + let screen = ctx.content_rect(); + let row_h = ctx.fonts_mut(|f| f.row_height(&code_editor::font())); + // The contents fit inside the app window with the window's frame and a margin around them. + let max = (screen.size() - FRAME - vec2(2.0 * MARGIN, 2.0 * MARGIN)).max(vec2(160.0, 120.0)); + let size = vec2(760.0, LINES as f32 * row_h + CHROME_HEIGHT).min(max); + let min = vec2(360.0, 200.0).min(max); + // At the right, below the formula bar, so the data the code reads stays in view. + let bar = formula_bar::BarLayout::get(ctx).field; + let top = if bar.is_positive() { bar.bottom() + 6.0 } else { screen.top() + 120.0 }; + let top = top.min(screen.bottom() - MARGIN - size.y - FRAME.y).max(screen.top() + MARGIN); + let left = (screen.right() - MARGIN - size.x - FRAME.x).max(screen.left() + MARGIN); + let mut open = true; + let shown = egui::Window::new(title) + .id(window_id()) + .collapsible(false) + .resizable(true) + .default_size(size) + .min_size(min) + .max_size(max) + .default_pos(pos2(left, top)) + .constrain_to(screen.shrink(MARGIN)) + .open(&mut open) + .show(ctx, |ui| contents(app, ui, &t)); + if let Some(w) = app.py_window.as_mut() { + w.rect = shown.map(|s| s.response.rect); + } + if !open { + let _ = request_close(app, true, false); + } +} + +/// What a key in the code does, besides editing it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Key { + /// Ctrl+Enter (Cmd+Enter on macOS). + Apply, + /// Esc. + Close, +} + +fn editor_key(ev: &egui::Event) -> Option { + match ev { + egui::Event::Key { key: egui::Key::Enter, pressed: true, modifiers: m, .. } if (m.ctrl || m.command || m.mac_cmd) && !m.alt => { + Some(Key::Apply) + } + egui::Event::Key { key: egui::Key::Escape, pressed: true, .. } => Some(Key::Close), + _ => None, + } +} + +fn contents(app: &mut SheetApp, ui: &mut Ui, t: &Tokens) { + egui::Panel::top("gridcraft.python_editor.header") + .frame(egui::Frame::NONE.inner_margin(egui::Margin { left: 0, right: 0, top: 0, bottom: 6 })) + .show(ui, |ui| header(app, ui, t)); + egui::Panel::bottom("gridcraft.python_editor.footer") + .frame(egui::Frame::NONE.inner_margin(egui::Margin { left: 0, right: 0, top: 6, bottom: 0 })) + .show(ui, |ui| footer(app, ui, t)); + egui::CentralPanel::default() + .frame(egui::Frame::NONE.fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).corner_radius(4.0).inner_margin(4.0)) + .show(ui, |ui| code(app, ui)); +} + +/// The PY tag, the cell's address, its return type, and whether there are unsaved changes. +fn header(app: &mut SheetApp, ui: &mut Ui, t: &Tokens) { + let Some(w) = app.py_window.as_ref() else { return }; + let lang = app.ui.language; + let object = window_cell(app, w).is_some_and(|(_, object)| object); + let address = w.address(); + let dirty = w.dirty(); + ui.horizontal(|ui| { + let (tag, _) = ui.allocate_exact_size(vec2(22.0, 15.0), egui::Sense::hover()); + crate::python_card::paint_tag(ui.painter(), tag, t); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if dirty { + ui.label(RichText::new(lang.tr(UNSAVED)).font(theme::ui_font(12.0)).color(t.text_dim)); + } + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + ui.add(egui::Label::new(RichText::new(address).font(theme::ui_bold(13.0)).color(t.text)).truncate()); + let output = lang.tr(if object { PYTHON_OBJECT } else { EXCEL_VALUE }); + ui.add(egui::Label::new(RichText::new(output).font(theme::ui_font(12.0)).color(t.text_dim)).truncate()); + }); + }); + }); +} + +/// The message under the code, the question about unsaved changes, or the keys, the code's +/// length and the Apply and Close buttons. +fn footer(app: &mut SheetApp, ui: &mut Ui, t: &Tokens) { + let lang = app.ui.language; + let Some(w) = app.py_window.as_ref() else { return }; + if w.pending.is_some() { + return question(app, ui, t); + } + let moved = stale(app, w); + let Some(w) = app.py_window.as_mut() else { return }; + let message = match &w.notice { + Some(Notice::TooLong) => Some(lang.tr(TOO_LONG).replace("{max}", &MAX_FORMULA_CHARS.to_string())), + Some(Notice::Refused(m)) => Some(m.clone()), + Some(Notice::Stale) => Some(lang.tr(STALE).replace("{cell}", &w.address())), + None if w.dirty() && moved => Some(lang.tr(CHANGED).replace("{cell}", &w.address())), + None => None, + }; + if let Some(m) = message { + ui.add(egui::Label::new(RichText::new(m).font(theme::ui_font(12.0)).color(t.danger)).wrap()); + ui.add_space(4.0); + } + let count = *w.count.get_or_insert_with(|| formula_len(&w.text)); + let keys = if ui.ctx().os() == egui::os::OperatingSystem::Mac { "Cmd+Enter" } else { "Ctrl+Enter" }; + let hint = lang.tr(HINT).replace("{apply}", keys); + let mut apply_now = false; + let mut close_now = false; + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.button(lang.tr(CLOSE)).clicked() { + close_now = true; + } + if ui.button(lang.tr(APPLY)).clicked() { + apply_now = true; + } + let color = if count > MAX_FORMULA_CHARS { t.danger } else { t.text_dim }; + ui.label(RichText::new(format!("{count} / {MAX_FORMULA_CHARS}")).font(theme::ui_font(11.5)).color(color)); + ui.with_layout(Layout::left_to_right(Align::Center), |ui| { + ui.add(egui::Label::new(RichText::new(hint).font(theme::ui_font(11.5)).color(t.text_dim)).truncate()); + }); + }); + if apply_now { + apply(app); + // The keyboard goes back to the code. + if let Some(w) = app.py_window.as_mut() { + w.focus = true; + } + } + if close_now { + let _ = request_close(app, true, false); + } +} + +/// "The code for Sheet1!A1 has unsaved changes." with Apply, Discard and Cancel (Esc, see +/// [`before_ui`]). +fn question(app: &mut SheetApp, ui: &mut Ui, t: &Tokens) { + let lang = app.ui.language; + let Some(w) = app.py_window.as_ref() else { return }; + let switched = w.pending == Some(Pending::Switched); + let text = if switched { + let title = app.session.documents().iter().find(|d| d.uid == w.doc).map(|d| d.display_title()).unwrap_or_default(); + lang.tr(ASK_IN).replace("{cell}", &w.address()).replace("{workbook}", &title) + } else { + lang.tr(ASK).replace("{cell}", &w.address()) + }; + ui.add(egui::Label::new(RichText::new(text).font(theme::ui_font(12.5)).color(t.text)).wrap()); + ui.add_space(4.0); + let mut picked = None; + let blocked = app.dialog.is_some() || app.message.is_some(); + ui.horizontal_wrapped(|ui| { + for (label, a) in [(APPLY, Answer::Apply), (DISCARD, Answer::Discard), (CANCEL, Answer::Cancel)] { + let b = ui.button(lang.tr(label)); + // The keyboard goes to the question while it is asked, not to the sheet behind it. + if a == Answer::Cancel && !blocked && ui.memory(|m| m.focused().is_none()) { + b.request_focus(); + } + if b.clicked() { + picked = Some(a); + } + } + }); + if let Some(a) = picked { + let ctx = ui.ctx().clone(); + answer(app, &ctx, a); + } +} + +/// The code, with line numbers, in the code editor. +fn code(app: &mut SheetApp, ui: &mut Ui) { + let blocked = app.dialog.is_some() || app.message.is_some(); + let name = app.ui.language.tr(TITLE).to_string(); + let Some(w) = app.py_window.as_mut() else { return }; + let interactive = !blocked && w.pending.is_none(); + let id = editor_id(w); + let mut caret = None; + // A window's first frame only measures it (its widgets aren't there for the focus), and a text + // field gives the focus up in a frame with a click elsewhere (the button that asked for it): + // the code takes the keyboard in the next frame without either. + let clicking = ui.input(|i| i.pointer.any_pressed() || i.pointer.any_released()); + if interactive && !ui.is_sizing_pass() && !clicking { + if std::mem::take(&mut w.focus) { + ui.memory_mut(|m| m.request_focus(id)); + } + caret = w.caret.take(); + } + let stop = |ev: &egui::Event| editor_key(ev).is_some(); + let accept = |new: &str, old: &str| accept_code(new, old); + let size = ui.available_size(); + let options = FieldOptions { + width: size.x, + max_height: size.y, + fill: true, + gutter: true, + interactive, + stop: &stop, + accept: Some(&accept), + keep_escape: true, + caret, + name: &name, + ..FieldOptions::new(Lang::Python) + }; + let out = code_editor::code_field(ui, id, &mut w.text, &options); + w.gutter = out.gutter; + if out.refused { + w.notice = Some(Notice::TooLong); + } else if out.response.changed() { + w.notice = None; + } + if out.refused || out.response.changed() { + w.count = None; + } + match out.stop.as_ref().and_then(editor_key) { + Some(Key::Apply) => { + apply(app); + } + Some(Key::Close) => { + let _ = request_close(app, true, false); + } + None => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_limit_is_the_one_python_set_checks_and_long_code_can_still_shrink() { + // The longest run of one letter that fits: its formula is the limit's length. + let n = (0..=MAX_FORMULA_CHARS).rev().find(|n| fits(&"x".repeat(*n))).unwrap_or(0); + let at = "x".repeat(n); + assert!(n > 8_000 && formula_len(&at) <= MAX_FORMULA_CHARS); + assert!(!fits(&format!("{at}y"))); + assert!(accept_code(&at, "")); + assert!(!accept_code(&format!("{at}y"), &at), "one more character is refused"); + // A quote counts twice in the formula. + let quotes = "\"".repeat(MAX_FORMULA_CHARS / 2); + assert!(!fits("es)); + // Code that came in past the limit can be shortened, not lengthened. + let over = format!("{at}yy"); + assert!(accept_code(&format!("{at}y"), &over)); + assert!(!accept_code(&format!("{at}yyy"), &over)); + // A huge paste is refused without building its formula. + assert!(!accept_code(&"z".repeat(50_000_000), "")); + } +} diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 3de56ed877..5edab403a5 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -54,7 +54,7 @@ pub(crate) fn tick(app: &mut SheetApp, ctx: &egui::Context) { pub(crate) fn show(app: &mut SheetApp, ctx: &egui::Context) { // The restart helper is scheduled before the app closes, so no update starts over unsaved work. - let clean = !app.session.documents().iter().any(|d| d.is_dirty()); + let clean = !app.session.documents().iter().any(|d| d.is_dirty()) && !crate::python_window::is_dirty(app); match app.updates_rt.updater.show_if(ctx, clean) { Action::OpenUrl(url) => { if let Some(open) = &app.services.open_url { diff --git a/crates/ui-egui/tests/doc_windows.rs b/crates/ui-egui/tests/doc_windows.rs index 46427f3bfb..7dc259932f 100644 --- a/crates/ui-egui/tests/doc_windows.rs +++ b/crates/ui-egui/tests/doc_windows.rs @@ -10,6 +10,7 @@ use std::cell::RefCell; use egui::{Context, RawInput, ViewportCommand, ViewportEvent, ViewportId, ViewportInfo}; use gridcraft_engine::{Session, core::CellRef, core::Value}; use gridcraft_ui_egui::doc_windows::DocWindows; +use gridcraft_ui_egui::python_window::{self, Answer, Pending}; use gridcraft_ui_egui::{SaveAnswer, SheetApp}; use serde_json::json; @@ -420,3 +421,120 @@ fn arrange_all_lays_out_the_native_windows_active_first() { let r = |x, y, w, h| craft_window::Rect::new(x, y, w, h); assert_eq!(tiled, [r(0.0, 0.0, 500.0, 400.0), r(500.0, 0.0, 500.0, 400.0), r(0.0, 400.0, 1000.0, 400.0)]); } + +/// Opens the Python editor on A1 with unsaved code in it. +fn unsaved_editor_code(desk: &mut Desk) { + desk.app.run("ui.pythonEditor", json!({"on": true})).unwrap(); + if let Some(w) = desk.app.py_window.as_mut() { + w.text = "q = 1".into(); + } + desk.frames(2); + assert!(gridcraft_ui_egui::python_window::is_dirty(&desk.app)); +} + +fn answer_editor(desk: &mut Desk, a: Answer) -> Vec<(ViewportId, ViewportCommand)> { + let mut sent = desk.frame_with(|app, ctx| python_window::answer(app, ctx, a)); + sent.extend(desk.frames(2)); + sent +} + +fn editor_pending(desk: &Desk) -> Option { + desk.app.py_window.as_ref().and_then(|w| w.pending.clone()) +} + +#[test] +fn closing_the_last_window_with_unsaved_editor_code_asks_first() { + let mut desk = Desk::new(true); + unsaved_editor_code(&mut desk); + let w = desk.window_of(0); + assert!(!desk.app.session.documents()[0].is_dirty(), "only the editor has unsaved work"); + // A programmatic close is refused. + let e = desk.app.run("window.close", json!({"window": w})).unwrap_err(); + assert!(e.contains("Python editor"), "{e}"); + // The close button asks in the window, which stays open. + let sent = desk.close(w); + assert!(matches!(editor_pending(&desk), Some(Pending::CloseWindow(_))), "{:?}", editor_pending(&desk)); + assert_eq!(desk.app.session.documents().len(), 1); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "{sent:?}"); + let sent = answer_editor(&mut desk, Answer::Cancel); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "Cancel keeps it open: {sent:?}"); + assert!(desk.app.py_window.as_ref().is_some_and(|x| x.text == "q = 1" && x.pending.is_none())); + // Discard goes on: the clean workbook closes with its last window, and the app quits. + desk.close(w); + let sent = answer_editor(&mut desk, Answer::Discard); + assert!(desk.app.py_window.is_none()); + assert!(desk.app.session.documents().is_empty()); + assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "{sent:?}"); +} + +#[test] +fn closing_one_of_two_windows_on_the_editors_workbook_does_not_ask() { + let mut desk = Desk::new(true); + desk.app.run("view.newWindow", json!({})).unwrap(); + desk.frames(3); + unsaved_editor_code(&mut desk); + let child = desk.windows.shown_windows().into_iter().find(|w| Some(*w) != desk.windows.root_window()).unwrap(); + desk.close(child); + assert_eq!(editor_pending(&desk), None, "the workbook stays open in its other window"); + assert_eq!(desk.app.session.windows().len(), 1); +} + +#[test] +fn quit_with_unsaved_editor_code_asks_first_and_goes_on_after() { + let mut desk = Desk::new(true); + unsaved_editor_code(&mut desk); + // Quit (⌘Q, the menu bar's Quit, the Dock) with no unsaved workbook. + let sent = desk.frame_with(|app, ctx| app.request_quit(ctx)); + assert_eq!(editor_pending(&desk), Some(Pending::Quit)); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "{sent:?}"); + assert!(!desk.app.allow_close); + let sent = answer_editor(&mut desk, Answer::Cancel); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "Cancel stops the quit: {sent:?}"); + assert!(desk.app.py_window.as_ref().is_some_and(|x| x.text == "q = 1")); + // Discard goes on with the quit, asking about each unsaved workbook next. + desk.app.run("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + desk.frame_with(|app, ctx| app.request_quit(ctx)); + answer_editor(&mut desk, Answer::Discard); + assert!(desk.app.py_window.is_none()); + assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("saveChanges")); + let sent = desk.answer(SaveAnswer::DontSave); + assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "{sent:?}"); +} + +#[test] +fn the_editor_stays_in_its_workbooks_window_while_another_workbooks_window_is_drawn() { + let mut desk = Desk::new(true); + desk.app.run("file.new", json!({})).unwrap(); + desk.frames(3); + let (w1, w2) = (desk.window_of(0), desk.window_of(1)); + desk.app.enter_window(w1); + desk.app.run("ui.pythonEditor", json!({"on": true})).unwrap(); + desk.frames(3); + let book1 = desk.app.session.documents()[0].uid; + assert_eq!(desk.app.py_window.as_ref().map(|w| w.doc), Some(book1), "drawing Book2's window doesn't close Book1's editor"); + if let Some(w) = desk.app.py_window.as_mut() { + w.text = "q = 1".into(); + } + desk.frames(3); + assert_eq!(editor_pending(&desk), None, "nor is drawing it a switch to another workbook"); + assert!(desk.app.py_window.as_ref().is_some_and(|w| w.text == "q = 1" && w.rect.is_some())); + + // Opening the editor from Book2's window asks in Book1's, which comes forward; after the + // answer the editor opens on Book2's cell, in Book2's window. + desk.app.enter_window(w2); + desk.frame_with(|app, _| { + let t = python_window::target(app, 0, CellRef::new(0, 0)).unwrap(); + python_window::request_open(app, t, None, true, false).unwrap(); + }); + desk.frames(2); + assert!(matches!(editor_pending(&desk), Some(Pending::Open { .. })), "{:?}", editor_pending(&desk)); + assert_eq!(desk.app.session.active_window(), Some(w1), "the question shows in the editor's window"); + answer_editor(&mut desk, Answer::Discard); + let book2 = desk.app.session.documents()[1].uid; + assert_eq!(desk.app.py_window.as_ref().map(|w| (w.doc, w.pending.is_none())), Some((book2, true))); + assert_eq!(desk.app.session.active_window(), Some(w2), "and comes forward in Book2's window"); + // Closing Book2 from its window closes the clean editor with it. + desk.app.run("file.close", json!({})).unwrap(); + desk.frames(3); + assert!(desk.app.py_window.is_none()); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 47b7b4e5b8..b8b0b07d13 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -75,6 +75,7 @@ mod popup_dismissal; mod protect_password; mod python_card; mod python_cell_edit; +mod python_editor; mod python_hidden; mod python_mode; mod python_ribbon; diff --git a/crates/ui-egui/tests/it/python_editor.rs b/crates/ui-egui/tests/it/python_editor.rs new file mode 100644 index 0000000000..7530839c1e --- /dev/null +++ b/crates/ui-egui/tests/it/python_editor.rs @@ -0,0 +1,1038 @@ +//! The Python editor window: its routes in and out, editing and applying the code through +//! `python.set`, the questions before unsaved code would be lost, the limit while typing, the +//! line-number gutter, multi-byte text, the control channel, and small windows. + +use egui::{Event, Key, Modifiers, Pos2, Rect, vec2}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_engine::model::python::MAX_FORMULA_CHARS; +use gridcraft_ui_egui::python_window::{self, APPLY, CANCEL, CLOSE, DISCARD, EDIT_IN_EDITOR, Pending, PyWindow}; +use serde_json::json; + +use crate::common::Harness; + +fn blank() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s +} + +/// B2 holds two lines of code. +fn with_code() -> Session { + let mut s = blank(); + s.execute("python.set", json!({"cell": "B2", "code": "x = 1\nx + 1"})).unwrap(); + s +} + +fn harness(session: Session) -> Harness { + sized(session, 1200.0, 800.0) +} + +fn sized(session: Session, w: f32, h: f32) -> Harness { + crate::common::harness_with(crate::common::builder().with_size(vec2(w, h)), crate::common::app(session), 4) +} + +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 }, + ] +} + +fn send(h: &mut Harness, events: impl IntoIterator) { + h.input_mut().events.extend(events); + h.run_steps(3); +} + +fn key(h: &mut Harness, key: Key, modifiers: Modifiers) { + send(h, press(key, modifiers)); +} + +fn type_text(h: &mut Harness, text: &str) { + send(h, [Event::Text(text.into())]); +} + +fn win(h: &Harness) -> Option { + h.state().py_window.clone() +} + +fn text(h: &Harness) -> String { + win(h).map(|w| w.text).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 ui(h: &mut Harness, p: serde_json::Value) -> Result { + let r = h.state_mut().run("ui.pythonEditor", p); + h.run_steps(3); + r +} + +/// Puts the window's caret at the end of the code. +fn to_end(h: &mut Harness) { + if let Some(w) = h.state_mut().py_window.as_mut() { + w.caret = Some(w.text.chars().count()); + } + h.run_steps(2); +} + +fn select(h: &mut Harness, cell: &str) { + h.state_mut().session.execute("selection.set", json!({"cell": cell})).unwrap(); + h.run_steps(2); +} + +/// Clicks the widget labelled `label` inside `area`. +fn click_in(h: &mut Harness, area: Rect, label: &str) { + let node = h.query_all_by_label(label).find(|n| area.contains(n.rect().center())); + let Some(node) = node else { panic!("no {label:?} in {area:?}") }; + node.click(); + h.run_steps(3); +} + +/// Clicks a button of the editor window. +fn click(h: &mut Harness, label: &str) { + let area = win(h).and_then(|w| w.rect).expect("the window is on screen"); + click_in(h, area, label); +} + +#[test] +fn the_window_opens_and_closes_by_each_route() { + let mut h = harness(with_code()); + // The control channel, without a dialog. + let r = ui(&mut h, json!({"on": true, "cell": "B2"})).unwrap(); + assert_eq!((r["open"].as_bool(), r["cell"].as_str()), (Some(true), Some("B2"))); + assert_eq!(text(&h), "x = 1\nx + 1"); + assert!(h.state().dialog.is_none() && h.state().message.is_none()); + assert!(h.query_all_by_label("Python Editor").count() >= 1, "the window shows"); + h.check_accessibility(); + assert_eq!(ui(&mut h, json!({"on": false})).unwrap()["open"], false); + assert!(win(&h).is_none()); + // The formula bar's button, on a selected Python cell; Esc closes the window. + select(&mut h, "B2"); + h.get_by_label(EDIT_IN_EDITOR).click(); + h.run_steps(3); + assert_eq!(win(&h).map(|w| w.cell), Some(CellRef::new(1, 1))); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(win(&h).is_none(), "Esc closes an editor without changes"); + // The cell menu's entry; the Close button. + h.state_mut().grid.context_menu = Some(Pos2::new(300.0, 300.0)); + h.run_steps(3); + let menu = h.ctx.memory(|m| m.area_rect(egui::Id::unique("cell_context"))).expect("the cell menu"); + click_in(&mut h, menu, EDIT_IN_EDITOR); + assert_eq!(win(&h).map(|w| w.cell), Some(CellRef::new(1, 1))); + assert!(h.state().grid.context_menu.is_none(), "the menu closed"); + click(&mut h, CLOSE); + assert!(win(&h).is_none()); + // No button and no menu entry on an ordinary cell. + select(&mut h, "D4"); + assert!(h.query_by_label(EDIT_IN_EDITOR).is_none()); + // Toggling from the control channel: without `on` it opens, then closes. + assert_eq!(ui(&mut h, json!({})).unwrap()["open"], true); + assert_eq!(win(&h).map(|w| w.cell), Some(CellRef::new(3, 3)), "on the active cell"); + assert_eq!(ui(&mut h, json!({})).unwrap()["open"], false); +} + +#[test] +fn python_mode_moves_its_edit_into_the_window() { + let mut h = harness(with_code()); + select(&mut h, "B2"); + key(&mut h, Key::F2, Modifiers::NONE); + assert!(h.state().editor.as_ref().is_some_and(|e| e.python)); + key(&mut h, Key::End, Modifiers::COMMAND); + type_text(&mut h, " # more"); + h.get_by_label(EDIT_IN_EDITOR).click(); + h.run_steps(3); + assert!(h.state().editor.is_none(), "the formula bar's edit ended"); + let w = win(&h).expect("open"); + assert_eq!(w.text, "x = 1\nx + 1 # more", "with the code as typed"); + assert!(w.dirty()); + assert_eq!(code(&h, "B2").as_deref(), Some("x = 1\nx + 1"), "nothing applied yet"); + // The keyboard is in the window: typing goes on where the caret was. + type_text(&mut h, "!"); + assert_eq!(text(&h), "x = 1\nx + 1 # more!"); +} + +#[test] +fn enter_tab_and_ctrl_enter_or_apply_commit_through_python_set() { + let mut h = harness(blank()); + select(&mut h, "C3"); + ui(&mut h, json!({"on": true})).unwrap(); + type_text(&mut h, "for i in range(3):"); + key(&mut h, Key::Enter, Modifiers::NONE); + type_text(&mut h, "print(i)"); + key(&mut h, Key::Enter, Modifiers::NONE); + key(&mut h, Key::Tab, Modifiers::SHIFT); + type_text(&mut h, "i"); + assert_eq!(text(&h), "for i in range(3):\n print(i)\ni", "Enter keeps the indentation, Shift+Tab takes it back"); + key(&mut h, Key::Enter, Modifiers::COMMAND); + assert_eq!(code(&h, "C3").as_deref(), Some("for i in range(3):\n print(i)\ni"), "Ctrl+Enter applied it"); + let w = win(&h).expect("the window stays open"); + assert!(!w.dirty()); + // Apply, after more typing. + type_text(&mut h, " * 2"); + assert!(win(&h).is_some_and(|w| w.dirty())); + assert!(h.query_by_label("Unsaved changes").is_some()); + click(&mut h, APPLY); + assert_eq!(code(&h, "C3").as_deref(), Some("for i in range(3):\n print(i)\ni * 2")); + assert!(win(&h).is_some_and(|w| !w.dirty())); + // An undo changes the cell: the window, with no changes of its own, shows it. + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(text(&h), "for i in range(3):\n print(i)\ni"); + // Each apply is one undo step, through python.set. + let journal: Vec = h.state().session.journal.iter().map(|(id, _)| id.clone()).collect(); + assert_eq!(journal.iter().filter(|id| *id == "python.set").count(), 2, "{journal:?}"); +} + +#[test] +fn esc_with_changes_asks_first() { + let mut h = harness(with_code()); + ui(&mut h, json!({"on": true, "cell": "B2"})).unwrap(); + to_end(&mut h); + type_text(&mut h, "\ny = 2"); + key(&mut h, Key::Escape, Modifiers::NONE); + let w = win(&h).expect("still open"); + assert_eq!(w.pending, Some(Pending::Close), "it asks"); + assert!(h.query_by_label("The code for Sheet1!B2 has unsaved changes.").is_some()); + // Typing goes nowhere while it asks. + type_text(&mut h, "zzz"); + assert_eq!(text(&h), "x = 1\nx + 1\ny = 2"); + assert_eq!(code(&h, "D1"), None); + // Cancel (and Esc) go back to the code. + click(&mut h, CANCEL); + assert_eq!(win(&h).and_then(|w| w.pending), None); + type_text(&mut h, "3"); + assert_eq!(text(&h), "x = 1\nx + 1\ny = 23", "the keyboard is back in the code"); + click(&mut h, CLOSE); + assert_eq!(win(&h).and_then(|w| w.pending), Some(Pending::Close), "Close asks too"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert_eq!(win(&h).and_then(|w| w.pending), None, "Esc answers Cancel"); + // Discard closes and changes nothing. + key(&mut h, Key::Escape, Modifiers::NONE); + click(&mut h, DISCARD); + assert!(win(&h).is_none()); + assert_eq!(code(&h, "B2").as_deref(), Some("x = 1\nx + 1")); + // Apply puts the code in, then closes. + ui(&mut h, json!({"on": true, "cell": "B2"})).unwrap(); + type_text(&mut h, "# top\n"); + key(&mut h, Key::Escape, Modifiers::NONE); + click(&mut h, APPLY); + assert!(win(&h).is_none()); + assert_eq!(code(&h, "B2").as_deref(), Some("# top\nx = 1\nx + 1")); +} + +#[test] +fn a_refused_commit_keeps_the_code_and_shows_the_message() { + let mut s = blank(); + s.execute("insert.table", json!({"range": "A1:B3", "header": true})).unwrap(); + let mut h = harness(s); + ui(&mut h, json!({"on": true, "cell": "A1"})).unwrap(); + type_text(&mut h, "len('abc')"); + key(&mut h, Key::Enter, Modifiers::COMMAND); + let w = win(&h).expect("still open"); + assert_eq!(w.text, "len('abc')", "the code is kept"); + assert!(w.dirty()); + let notice = python_window::state(h.state())["notice"].as_str().unwrap_or_default().to_string(); + assert!(notice.contains("table header"), "the command's message: {notice}"); + assert!(h.query_by_label(¬ice).is_some(), "shown under the code"); + assert_eq!(code(&h, "A1"), None); + assert!(h.state().message.is_none(), "in the window, not a message box over it"); + // Apply from the question refuses the same way and keeps the window open. + key(&mut h, Key::Escape, Modifiers::NONE); + click(&mut h, APPLY); + assert!(win(&h).is_some_and(|w| w.text == "len('abc')" && w.pending.is_none())); + // Typing again clears the message. + type_text(&mut h, " "); + assert_eq!(text(&h), "len('abc') "); + assert_eq!(python_window::state(h.state())["notice"], serde_json::Value::Null); +} + +/// The longest run of `c` that fits in a cell. +fn longest(c: char) -> String { + let n = (0..=MAX_FORMULA_CHARS).rev().find(|n| python_window::fits(&c.to_string().repeat(*n))).unwrap_or(0); + c.to_string().repeat(n) +} + +#[test] +fn typing_and_pasting_past_the_limit_are_refused_with_a_message() { + let mut h = harness(blank()); + ui(&mut h, json!({"on": true})).unwrap(); + let full = longest('y'); + let n = full.chars().count(); + let at = full[..n - 3].to_string(); + if let Some(w) = h.state_mut().py_window.as_mut() { + w.text = at.clone(); + w.caret = Some(n - 3); + } + h.run_steps(2); + // A paste that would go past the limit is refused whole. + send(&mut h, [Event::Paste("abcd".into())]); + assert_eq!(text(&h), at, "nothing of the paste went in"); + let notice = python_window::state(h.state())["notice"].as_str().unwrap_or_default().to_string(); + assert!(notice.contains(&MAX_FORMULA_CHARS.to_string()), "{notice}"); + assert!(h.query_by_label(¬ice).is_some()); + // Typing up to the limit works, one more character doesn't; each keystroke on its own. + send(&mut h, [Event::Text("a".into()), Event::Text("b".into()), Event::Text("c".into()), Event::Text("d".into())]); + assert_eq!(text(&h), format!("{at}abc"), "three fit, the fourth is refused"); + assert!(python_window::fits(&text(&h))); + // Enter (with indentation) past the limit is refused too; Backspace still works and clears + // the message. + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(text(&h).chars().count(), n); + key(&mut h, Key::Backspace, Modifiers::NONE); + assert_eq!(text(&h).chars().count(), n - 1); + assert_eq!(python_window::state(h.state())["notice"], serde_json::Value::Null); + // Quotes count twice in the formula: one more fits no longer. + type_text(&mut h, "\""); + assert_eq!(text(&h).chars().count(), n - 1, "a quote needs two places"); + // The applied code is what python.set accepts. + key(&mut h, Key::Enter, Modifiers::COMMAND); + assert_eq!(code(&h, "A1").map(|c| c.chars().count()), Some(n - 1)); +} + +#[test] +fn multi_byte_text_goes_through_edits_without_a_panic() { + let mut h = harness(blank()); + ui(&mut h, json!({"on": true})).unwrap(); + type_text(&mut h, "café = 'Crème brûlée' # 日本語 \u{1F600}"); + key(&mut h, Key::Enter, Modifiers::NONE); + type_text(&mut h, "if café:"); + key(&mut h, Key::Enter, Modifiers::NONE); + type_text(&mut h, "x = '字'"); + assert_eq!(text(&h), "café = 'Crème brûlée' # 日本語 \u{1F600}\nif café:\n x = '字'"); + // Caret moves, selections, deletions and indents across the multi-byte characters. + for (k, m) in [ + (Key::ArrowLeft, Modifiers::NONE), + (Key::ArrowLeft, Modifiers::SHIFT), + (Key::Backspace, Modifiers::NONE), + (Key::ArrowUp, Modifiers::NONE), + (Key::Home, Modifiers::NONE), + (Key::Tab, Modifiers::NONE), + (Key::ArrowUp, Modifiers::NONE), + (Key::End, Modifiers::NONE), + (Key::Backspace, Modifiers::NONE), + (Key::Delete, Modifiers::NONE), + (Key::A, Modifiers::COMMAND), + (Key::Tab, Modifiers::NONE), + (Key::Tab, Modifiers::SHIFT), + (Key::ArrowRight, Modifiers::NONE), + ] { + key(&mut h, k, m); + } + // Backspace took the emoji, Delete the line break after it (joining the indented line). + assert_eq!(text(&h), "café = 'Crème brûlée' # 日本語 if café:\n x = ''"); + // A paste with Windows line breaks keeps code's \n. + send(&mut h, [Event::Paste("\r\né\r\n字".into())]); + assert!(!text(&h).contains('\r')); + assert!(text(&h).ends_with("\né\n字"), "{:?}", text(&h)); + key(&mut h, Key::Enter, Modifiers::COMMAND); + assert_eq!(code(&h, "A1"), Some(text(&h))); + // Reopened from the formula bar with the caret after the emoji (a char index, not a byte). + ui(&mut h, json!({"on": false})).unwrap(); + select(&mut h, "B1"); + h.state_mut().toggle_python_mode(); + h.run_steps(3); + type_text(&mut h, "s = '\u{1F600}é字'"); + h.get_by_label(EDIT_IN_EDITOR).click(); + h.run_steps(3); + type_text(&mut h, "!"); + assert_eq!(text(&h), "s = '\u{1F600}é字'!"); +} + +/// Opens the editor on code of `lines` lines and returns its gutter's width. +fn gutter_for(lines: usize) -> f32 { + let code = (1..=lines).map(|i| format!("v{i} = {i}")).collect::>().join("\n"); + let mut s = blank(); + s.execute("python.set", json!({"cell": "A1", "code": code})).unwrap(); + let mut h = harness(s); + ui(&mut h, json!({"on": true, "cell": "A1"})).unwrap(); + let painted: Vec = h + .output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.job.text == lines.to_string() || t.galley.job.text == "1" => Some(t.galley.job.text.clone()), + _ => None, + }) + .collect(); + assert!(painted.contains(&"1".to_string()), "the first line's number is painted"); + win(&h).map(|w| w.gutter).unwrap_or(0.0) +} + +#[test] +fn the_gutter_widens_with_the_digit_count() { + let (g9, g10, g99, g100) = (gutter_for(9), gutter_for(10), gutter_for(99), gutter_for(100)); + assert!(g9 > 0.0); + assert!(g10 > g9, "two digits: {g9} then {g10}"); + assert_eq!(g10, g99, "still two digits"); + assert!(g100 > g99, "three digits: {g99} then {g100}"); + let digit = (g100 - g99).max(g10 - g9); + assert!((g10 - g9 - digit).abs() < 0.01 && (g100 - g99 - digit).abs() < 0.01, "one digit's width each time"); +} + +#[test] +fn only_the_visible_line_numbers_are_painted() { + let code = (1..=300).map(|i| format!("v{i} = {i}")).collect::>().join("\n"); + let mut s = blank(); + s.execute("python.set", json!({"cell": "A1", "code": code})).unwrap(); + let mut h = harness(s); + ui(&mut h, json!({"on": true, "cell": "A1"})).unwrap(); + let numbers = |h: &Harness| -> Vec { + let area = win(h).and_then(|w| w.rect).unwrap(); + h.output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(t) if area.contains(t.pos) => t.galley.job.text.parse::().ok(), + _ => None, + }) + .collect() + }; + let shown = numbers(&h); + assert!(shown.contains(&1) && shown.len() < 80, "the first rows only: {}", shown.len()); + // The view follows the caret to the end, and the numbers there are painted. + to_end(&mut h); + type_text(&mut h, " "); + let shown = numbers(&h); + assert!(shown.contains(&300) && !shown.contains(&1), "{shown:?}"); +} + +#[test] +fn another_cell_with_unsaved_changes_asks_first() { + let mut h = harness(with_code()); + ui(&mut h, json!({"on": true, "cell": "A1"})).unwrap(); + type_text(&mut h, "a = 1"); + // The cell menu on B2 asks; Cancel keeps A1's code. + select(&mut h, "B2"); + python_window::open_from_menu(h.state_mut()); + h.run_steps(3); + let w = win(&h).unwrap(); + assert_eq!((w.cell, w.text.as_str()), (CellRef::new(0, 0), "a = 1")); + assert!(matches!(w.pending, Some(Pending::Open { .. })), "it asks"); + click(&mut h, CANCEL); + assert_eq!(win(&h).map(|w| (w.cell, w.text)), Some((CellRef::new(0, 0), "a = 1".to_string()))); + // Discard opens B2 and leaves A1 as it was. + python_window::open_from_menu(h.state_mut()); + h.run_steps(3); + click(&mut h, DISCARD); + assert_eq!(win(&h).map(|w| (w.cell, w.text)), Some((CellRef::new(1, 1), "x = 1\nx + 1".to_string()))); + assert_eq!(code(&h, "A1"), None); + // Apply puts B2's changes in, then opens on the formula bar's cell with its code. + type_text(&mut h, "# b2\n"); + select(&mut h, "C3"); + h.state_mut().toggle_python_mode(); + h.run_steps(3); + type_text(&mut h, "c = 3"); + h.get_by_label(EDIT_IN_EDITOR).click(); + h.run_steps(3); + assert!(h.state().editor.is_some(), "the formula bar keeps its edit while the window asks"); + click(&mut h, APPLY); + assert_eq!(code(&h, "B2").as_deref(), Some("# b2\nx = 1\nx + 1")); + assert_eq!(win(&h).map(|w| (w.cell, w.text)), Some((CellRef::new(2, 2), "c = 3".to_string()))); + assert!(h.state().editor.is_none(), "and its edit moved into the window"); + // The control channel never asks: it refuses, unless forced. + let e = ui(&mut h, json!({"on": true, "cell": "D4"})).unwrap_err(); + assert!(e.contains("unsaved changes to Sheet1!C3"), "{e}"); + assert!(win(&h).is_some_and(|w| w.cell == CellRef::new(2, 2) && w.pending.is_none())); + assert!(h.state().dialog.is_none() && h.state().message.is_none()); + assert_eq!(ui(&mut h, json!({"on": true, "cell": "Sheet1!D4", "force": true})).unwrap()["cell"], "D4"); + assert_eq!(code(&h, "C3"), None, "force discarded the code"); + // The same cell again keeps the window's code. + type_text(&mut h, "d"); + ui(&mut h, json!({"on": true, "cell": "D4"})).unwrap(); + assert_eq!(text(&h), "d"); +} + +#[test] +fn the_control_channel_opens_without_a_prompt_and_refuses_to_close_unsaved_code() { + let mut h = harness(with_code()); + assert!(ui(&mut h, json!({"on": true, "cell": "Nope!A1"})).is_err()); + assert!(ui(&mut h, json!({"on": true, "cell": "A0"})).is_err()); + assert!(ui(&mut h, json!({"on": "yes"})).is_err()); + assert!(win(&h).is_none(), "a bad request opens nothing"); + ui(&mut h, json!({"on": true, "cell": "b2"})).unwrap(); + type_text(&mut h, "1"); + let e = ui(&mut h, json!({"on": false})).unwrap_err(); + assert!(e.contains("force"), "{e}"); + let state = python_window::state(h.state()); + assert_eq!((state["open"].as_bool(), state["dirty"].as_bool(), state["pending"].as_str()), (Some(true), Some(true), None)); + assert!(h.state().dialog.is_none() && h.state().message.is_none(), "no question, no dialog"); + let inspect = gridcraft_ui_egui::control::inspect(h.state(), &h.ctx); + assert_eq!(inspect["pythonEditor"]["cell"], "B2"); + assert_eq!(ui(&mut h, json!({"on": false, "force": true})).unwrap()["open"], false); + assert_eq!(code(&h, "B2").as_deref(), Some("x = 1\nx + 1")); + // Every hostile parameter is an error or ignored, never a panic. + for p in [json!(null), json!([]), json!({"cell": 5}), json!({"cell": ""}), json!({"cell": "Sheet1!"}), json!({"force": "x"})] { + let _ = ui(&mut h, p); + let _ = ui(&mut h, json!({"on": false, "force": true})); + } +} + +#[test] +fn closing_or_leaving_the_workbook_asks_about_unsaved_code() { + let mut h = harness(blank()); + let first = h.state().session.active().unwrap().uid; + ui(&mut h, json!({"on": true})).unwrap(); + type_text(&mut h, "a = 1"); + // A programmatic close is refused; File › Close asks in the window. + assert!(h.state_mut().run("file.close", json!({})).unwrap_err().contains("Python editor")); + h.state_mut().run_or_alert("file.close", json!({})); + h.run_steps(3); + assert!(matches!(win(&h).and_then(|w| w.pending), Some(Pending::CloseWorkbook(_)))); + click(&mut h, CANCEL); + assert_eq!(h.state().session.documents().len(), 1, "Cancel keeps the workbook"); + // Switching to another workbook asks too; Cancel goes back. + h.state_mut().run("file.new", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(win(&h).and_then(|w| w.pending), Some(Pending::Switched)); + let title = h.state().session.documents()[0].display_title(); + assert!(h.query_by_label(&format!("The code for Sheet1!A1 in {title} has unsaved changes.")).is_some()); + click(&mut h, CANCEL); + assert_eq!(h.state().session.active().map(|d| d.uid), Some(first), "back to the code's workbook"); + // Apply after a switch puts the code in its own workbook, not the active one. + h.state_mut().session.set_active(1); + h.run_steps(3); + assert_eq!(win(&h).and_then(|w| w.pending), Some(Pending::Switched)); + click(&mut h, APPLY); + assert!(win(&h).is_none()); + assert_eq!(code(&h, "A1"), None, "the active workbook has no code"); + let first_code = h.state().session.documents()[0].wb.sheet(0).and_then(|s| s.cell(CellRef::new(0, 0))).and_then(|c| c.py.clone()); + assert_eq!(first_code.map(|p| p.code.clone()).as_deref(), Some("a = 1")); + // An editor without changes closes with its workbook, without asking. + h.state_mut().session.set_active(0); + ui(&mut h, json!({"on": true})).unwrap(); + h.state_mut().run_or_alert("file.close", json!({"force": true})); + h.run_steps(3); + assert!(win(&h).is_none()); + assert_eq!(h.state().session.documents().len(), 1); + // With changes, Discard goes on to close the workbook (which asks about its own changes). + ui(&mut h, json!({"on": true})).unwrap(); + type_text(&mut h, "b"); + h.state_mut().run_or_alert("file.close", json!({})); + h.run_steps(3); + click(&mut h, DISCARD); + assert!(win(&h).is_none()); + assert!(h.state().session.documents().iter().all(|d| d.uid != first)); +} + +#[test] +fn quitting_with_unsaved_code_asks_first() { + let mut h = harness(blank()); + ui(&mut h, json!({"on": true})).unwrap(); + type_text(&mut h, "q"); + h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + h.run_steps(2); + assert_eq!(win(&h).and_then(|w| w.pending), Some(Pending::Quit)); + assert!(!h.state().allow_close); + click(&mut h, CANCEL); + assert!(win(&h).is_some_and(|w| w.pending.is_none() && w.text == "q")); + // Discard goes on with the quit: no workbook is unsaved, so the app closes. + h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + h.run_steps(2); + click(&mut h, DISCARD); + assert!(win(&h).is_none()); + assert!(h.state().allow_close, "the quit went on"); +} + +fn control(h: &mut Harness, method: &str, p: serde_json::Value) -> serde_json::Value { + let ctx = h.ctx.clone(); + match gridcraft_ui_egui::control::handle(h.state_mut(), &ctx, method, &p) { + gridcraft_ui_egui::control::Outcome::Done(v) => v, + gridcraft_ui_egui::control::Outcome::Screenshot { .. } => json!(null), + } +} + +#[test] +fn app_quit_over_the_control_channel_refuses_unsaved_code() { + let mut h = harness(blank()); + ui(&mut h, json!({"on": true})).unwrap(); + type_text(&mut h, "q"); + // Programmatic calls never ask: the quit is refused, and the code stays. + let r = control(&mut h, "app.quit", json!({})); + assert_eq!(r["ok"], json!(false), "{r}"); + assert!(r["error"].as_str().unwrap_or_default().contains("Python editor has unsaved changes to Sheet1!A1"), "{r}"); + assert!(!h.state().allow_close); + assert!(win(&h).is_some_and(|w| w.pending.is_none() && w.text == "q"), "nothing asks"); + // Once the code is discarded, the quit goes through. + ui(&mut h, json!({"on": false, "force": true})).unwrap(); + let r = control(&mut h, "app.quit", json!({})); + assert_eq!(r["ok"], json!(true), "{r}"); + assert!(h.state().allow_close); +} + +#[test] +fn the_window_stays_inside_a_600_by_400_app() { + for language in ["en", "de"] { + let mut h = sized(with_code(), 600.0, 400.0); + h.state_mut().run("app.language.set", json!({"language": language})).unwrap(); + ui(&mut h, json!({"on": true, "cell": "B2"})).unwrap(); + type_text(&mut h, "y"); + h.run_steps(4); + let screen = Rect::from_min_size(Pos2::ZERO, vec2(600.0, 400.0)); + let area = win(&h).and_then(|w| w.rect).unwrap(); + assert!(screen.contains_rect(area), "{language}: {area:?}"); + let lang = h.state().ui.language; + for label in [APPLY, CLOSE] { + let node = h.query_all_by_label(lang.tr(label)).find(|n| area.contains(n.rect().center())).expect(label); + assert!(area.contains_rect(node.rect()), "{language}: {label} at {:?} in {area:?}", node.rect()); + } + // The question's buttons too. + key(&mut h, Key::Escape, Modifiers::NONE); + for label in [APPLY, DISCARD, CANCEL] { + let node = h.query_all_by_label(lang.tr(label)).find(|n| area.contains(n.rect().center())).expect(label); + assert!(screen.contains_rect(node.rect()), "{language}: {label} at {:?}", node.rect()); + } + } + // Opened in a large app window, it shrinks with it. + let mut h = harness(with_code()); + ui(&mut h, json!({"on": true, "cell": "B2"})).unwrap(); + let big = win(&h).and_then(|w| w.rect).unwrap(); + h.set_size(vec2(600.0, 400.0)); + h.run_steps(4); + let small = win(&h).and_then(|w| w.rect).unwrap(); + assert!(small.height() < big.height()); + assert!(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 400.0)).contains_rect(small), "{small:?}"); +} + +#[test] +#[allow(clippy::disallowed_methods)] // the clock is read off wasm, in a test +fn code_at_the_limit_stays_fast() { + // About 8,000 characters in 200 lines. + let mut lines = Vec::new(); + let mut i = 0; + loop { + let next = format!("v{i} = round(v{} * 1.05, 2) + {i} # step", i.max(1) - 1); + let candidate = if lines.is_empty() { next.clone() } else { format!("{}\n{next}", lines.join("\n")) }; + if !python_window::fits(&candidate) { + break; + } + lines.push(next); + i += 1; + } + let code = lines.join("\n"); + assert!(code.chars().count() > 7_500, "{}", code.chars().count()); + let mut s = blank(); + s.execute("python.set", json!({"cell": "A1", "code": code})).unwrap(); + let mut h = harness(s); + let frames = |h: &mut Harness, n: u32, typing: bool| { + let start = std::time::Instant::now(); + for _ in 0..n { + if typing { + h.input_mut().events.push(Event::Key { + key: Key::Backspace, + physical_key: None, + pressed: true, + repeat: false, + modifiers: Modifiers::NONE, + }); + } + h.run_steps(1); + } + start.elapsed().as_secs_f64() * 1000.0 / f64::from(n) + }; + let base = frames(&mut h, 20, false); + ui(&mut h, json!({"on": true, "cell": "A1"})).unwrap(); + to_end(&mut h); + let idle = frames(&mut h, 20, false); + let typing = frames(&mut h, 20, true); + eprintln!( + "python editor, {} chars, {} lines: {base:.1} ms a frame without it, {idle:.1} ms open, {typing:.1} ms typing", + code.chars().count(), + lines.len() + ); + assert_eq!(text(&h).chars().count(), code.chars().count() - 20, "each frame took its Backspace"); + // A loose bound, for a debug build on a shared machine: it catches work that grows with the + // square of the code, not a slow machine. + assert!(typing < base + 250.0, "{typing} ms a frame"); +} + +#[test] +fn the_window_follows_its_sheet_and_never_writes_to_another() { + let mut s = blank(); + s.execute("home.insertSheet", json!({"name": "Data"})).unwrap(); + s.execute("python.set", json!({"cell": "Data!B2", "code": "d = 1"})).unwrap(); + s.execute("python.set", json!({"cell": "Sheet1!B2", "code": "s = 1"})).unwrap(); + let mut h = harness(s); + ui(&mut h, json!({"on": true, "cell": "Data!B2"})).unwrap(); + assert_eq!(text(&h), "d = 1"); + let sheet_of = |h: &Harness| win(h).map(|w| (w.sheet, w.sheet_name)).unwrap(); + let data = sheet_of(&h).0; + // Moved: followed by name. + h.state_mut().run("sheet.move", json!({"sheet": "Data", "to": 1 - data})).unwrap(); + h.run_steps(3); + assert_eq!(sheet_of(&h), (1 - data, "Data".to_string())); + // Renamed: followed in place. + h.state_mut().run("sheet.rename", json!({"sheet": "Data", "name": "Inputs"})).unwrap(); + h.run_steps(3); + assert_eq!(sheet_of(&h).1, "Inputs"); + to_end(&mut h); + type_text(&mut h, " + 1"); + click(&mut h, APPLY); + let code_on = |h: &Harness, sheet: &str| { + let d = h.state().session.active().unwrap(); + let sh = &d.wb.sheets[d.wb.sheet_index(sheet).unwrap()]; + sh.cell(CellRef::new(1, 1)).and_then(|c| c.py.as_ref().map(|p| p.code.clone())) + }; + assert_eq!(code_on(&h, "Inputs").as_deref(), Some("d = 1 + 1")); + assert_eq!(code_on(&h, "Sheet1").as_deref(), Some("s = 1")); + // Deleted with unsaved code: Apply is refused rather than writing to the sheet in its place. + type_text(&mut h, "!"); + h.state_mut().run("home.deleteSheet", json!({"sheet": "Inputs"})).unwrap(); + h.run_steps(3); + assert!(win(&h).is_some_and(|w| w.dirty()), "the code is kept"); + key(&mut h, Key::Enter, Modifiers::COMMAND); + assert_eq!(code_on(&h, "Sheet1").as_deref(), Some("s = 1"), "the other sheet is untouched"); + let notice = python_window::state(h.state())["notice"].as_str().unwrap_or_default().to_string(); + assert!(notice.contains("Inputs"), "{notice}"); + // A new sheet in its place is not taken for it either. + h.state_mut().run("home.insertSheet", json!({"name": "Other"})).unwrap(); + h.run_steps(3); + key(&mut h, Key::Enter, Modifiers::COMMAND); + assert!( + h.state() + .session + .active() + .unwrap() + .wb + .sheets + .iter() + .all(|sh| sh.cell(CellRef::new(1, 1)).and_then(|c| c.py.as_ref()).is_none_or(|p| p.code == "s = 1")) + ); + h.state_mut().run("edit.undo", json!({})).unwrap(); + // Undoing the deletion brings the sheet back, and Apply works again. + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + click(&mut h, APPLY); + assert_eq!(code_on(&h, "Inputs").as_deref(), Some("d = 1 + 1!")); + // Without unsaved code the window closes with its sheet. + h.state_mut().run("home.deleteSheet", json!({"sheet": "Inputs"})).unwrap(); + h.run_steps(3); + assert!(win(&h).is_none()); +} + +/// What `cell` holds on the active sheet, as the formula bar shows it. +fn input(h: &Harness, cell: &str) -> String { + let d = h.state().session.active().unwrap(); + d.wb.active().unwrap().input_text(CellRef::parse(cell).unwrap()) +} + +/// The refusal shown when the window's cell changed under it. +fn stale_message(cell: &str) -> String { + python_window::STALE.replace("{cell}", cell) +} + +fn notice(h: &Harness) -> Option { + python_window::state(h.state())["notice"].as_str().map(str::to_string) +} + +#[test] +fn a_row_inserted_above_the_cell_refuses_apply_rather_than_overwrite_what_moved_in() { + let mut s = with_code(); + s.execute("cell.set", json!({"cell": "B1", "input": "keep"})).unwrap(); + let mut h = harness(s); + ui(&mut h, json!({"on": true, "cell": "B2"})).unwrap(); + to_end(&mut h); + type_text(&mut h, " + 2"); + // B2's code moves to B3, and B1's text to B2. + h.state_mut().run("home.insertRows", json!({"rows": "1:1"})).unwrap(); + h.run_steps(3); + assert_eq!((input(&h, "B2"), code(&h, "B3").as_deref()), ("keep".to_string(), Some("x = 1\nx + 1"))); + key(&mut h, Key::Enter, Modifiers::COMMAND); + assert_eq!(input(&h, "B2"), "keep", "the text that moved to B2 is not replaced"); + assert_eq!(code(&h, "B2"), None); + assert_eq!(code(&h, "B3").as_deref(), Some("x = 1\nx + 1"), "nor the code that moved away"); + let w = win(&h).expect("the window stays open"); + assert_eq!(w.text, "x = 1\nx + 1 + 2", "the code is kept"); + let message = stale_message("Sheet1!B2"); + assert_eq!(notice(&h).as_deref(), Some(message.as_str())); + assert!(h.query_by_label(&message).is_some(), "shown in the window"); + assert!(h.state().message.is_none(), "not in a message box over it"); + // Apply from the question about unsaved changes refuses the same way. + key(&mut h, Key::Escape, Modifiers::NONE); + click(&mut h, APPLY); + assert!(win(&h).is_some_and(|w| w.text == "x = 1\nx + 1 + 2" && w.pending.is_none())); + assert_eq!(input(&h, "B2"), "keep"); + // The control channel doesn't take the address for the same cell: it refuses to replace the + // unsaved code, and with `force` opens afresh on what B2 holds now. + let e = ui(&mut h, json!({"on": true, "cell": "B2"})).unwrap_err(); + assert!(e.contains("unsaved changes"), "{e}"); + assert!(win(&h).is_some_and(|w| w.text == "x = 1\nx + 1 + 2")); + ui(&mut h, json!({"on": true, "cell": "B2", "force": true})).unwrap(); + type_text(&mut h, "y = 3"); + key(&mut h, Key::Enter, Modifiers::COMMAND); + assert_eq!(code(&h, "B2").as_deref(), Some("y = 3"), "opened again on the cell, Apply works"); +} + +#[test] +fn a_column_inserted_to_the_left_refuses_apply_rather_than_overwrite_a_formula() { + let mut s = with_code(); + s.execute("cell.set", json!({"cell": "A2", "input": "=1+1"})).unwrap(); + let mut h = harness(s); + ui(&mut h, json!({"on": true, "cell": "B2"})).unwrap(); + to_end(&mut h); + type_text(&mut h, " + 2"); + h.state_mut().run("home.insertColumns", json!({"cols": "A:A"})).unwrap(); + h.run_steps(3); + assert_eq!(input(&h, "B2"), "=1+1", "the formula moved to B2"); + click(&mut h, APPLY); + assert_eq!(input(&h, "B2"), "=1+1", "and is not replaced"); + assert_eq!(code(&h, "C2").as_deref(), Some("x = 1\nx + 1")); + assert!(win(&h).is_some_and(|w| w.text == "x = 1\nx + 1 + 2")); + let message = stale_message("Sheet1!B2"); + assert!(h.query_by_label(&message).is_some(), "{:?}", notice(&h)); +} + +#[test] +fn code_changed_from_outside_refuses_apply() { + let mut h = harness(with_code()); + ui(&mut h, json!({"on": true, "cell": "B2"})).unwrap(); + to_end(&mut h); + type_text(&mut h, " + 2"); + h.state_mut().run("python.set", json!({"cell": "B2", "code": "z = 9"})).unwrap(); + h.run_steps(3); + // Before Apply, the window says the cell changed. + let changed = python_window::CHANGED.replace("{cell}", "Sheet1!B2"); + assert!(h.query_by_label(&changed).is_some(), "the change is shown before Apply"); + key(&mut h, Key::Enter, Modifiers::COMMAND); + assert_eq!(code(&h, "B2").as_deref(), Some("z = 9"), "the other code is not replaced"); + assert_eq!(text(&h), "x = 1\nx + 1 + 2"); + assert_eq!(notice(&h), Some(stale_message("Sheet1!B2"))); + // A changed return type counts too. + let mut h = harness(with_code()); + ui(&mut h, json!({"on": true, "cell": "B2"})).unwrap(); + type_text(&mut h, "# note\n"); + h.state_mut().run("python.set", json!({"cell": "B2", "code": "x = 1\nx + 1", "output": "object"})).unwrap(); + h.run_steps(3); + key(&mut h, Key::Enter, Modifiers::COMMAND); + assert_eq!(code(&h, "B2").as_deref(), Some("x = 1\nx + 1")); + assert_eq!(notice(&h), Some(stale_message("Sheet1!B2"))); +} + +#[test] +fn an_unrelated_edit_still_allows_apply() { + let mut h = harness(with_code()); + ui(&mut h, json!({"on": true, "cell": "B2"})).unwrap(); + to_end(&mut h); + type_text(&mut h, " + 2"); + // Other cells, the cell's format, and a row inserted below it are not changes to the cell. + h.state_mut().run("cell.set", json!({"cell": "D9", "input": "5"})).unwrap(); + h.state_mut().run("home.bold", json!({"range": "B2"})).unwrap(); + h.state_mut().run("home.insertRows", json!({"rows": "5:5"})).unwrap(); + h.run_steps(3); + key(&mut h, Key::Enter, Modifiers::COMMAND); + assert_eq!(code(&h, "B2").as_deref(), Some("x = 1\nx + 1 + 2")); + assert_eq!(notice(&h), None); + // A row inserted above it then is a change. + type_text(&mut h, "!"); + h.state_mut().run("home.insertRows", json!({"rows": "1:1"})).unwrap(); + h.run_steps(3); + key(&mut h, Key::Enter, Modifiers::COMMAND); + assert_eq!(code(&h, "B2"), None); + assert_eq!(code(&h, "B3").as_deref(), Some("x = 1\nx + 1 + 2")); + assert_eq!(notice(&h), Some(stale_message("Sheet1!B2"))); +} + +#[test] +fn apply_after_apply_works_because_each_apply_takes_the_cell_as_it_is_now() { + let mut h = harness(with_code()); + ui(&mut h, json!({"on": true, "cell": "B2"})).unwrap(); + to_end(&mut h); + type_text(&mut h, " + 2"); + key(&mut h, Key::Enter, Modifiers::COMMAND); + type_text(&mut h, " + 3"); + click(&mut h, APPLY); + assert_eq!(code(&h, "B2").as_deref(), Some("x = 1\nx + 1 + 2 + 3")); + assert_eq!(notice(&h), None); + // The reference is the code last applied: a change from outside after it is still caught. + type_text(&mut h, " + 4"); + h.state_mut().run("python.set", json!({"cell": "B2", "code": "x = 1\nx + 1 + 2"})).unwrap(); + h.run_steps(3); + key(&mut h, Key::Enter, Modifiers::COMMAND); + assert_eq!(code(&h, "B2").as_deref(), Some("x = 1\nx + 1 + 2")); + assert_eq!(notice(&h), Some(stale_message("Sheet1!B2"))); +} + +/// B2 holds code, formatted Hidden (and unlocked); the sheet is protected when `protected`. +fn hidden_code(protected: bool) -> Harness { + let mut s = with_code(); + s.execute("home.formatCells", json!({"range": "B2", "style": {"protection": {"locked": false, "hidden": true}}})).unwrap(); + if protected { + s.execute("review.protectSheet", json!({})).unwrap(); + } + s.execute("selection.set", json!({"cell": "B2"})).unwrap(); + harness(s) +} + +#[test] +fn the_control_channel_refuses_hidden_code_on_a_protected_sheet() { + let mut h = hidden_code(true); + let e = ui(&mut h, json!({"on": true, "cell": "B2"})).unwrap_err(); + assert!(e.contains("B2 are hidden because the sheet is protected"), "{e}"); + assert!(win(&h).is_none()); + // Without `cell` (the active cell) and without `on` too. + assert!(ui(&mut h, json!({})).is_err()); + assert!(win(&h).is_none()); + assert!(h.state().dialog.is_none() && h.state().message.is_none(), "no dialog"); +} + +#[test] +fn the_formula_bar_offers_no_editor_button_on_hidden_code() { + let mut h = hidden_code(true); + assert!(h.query_by_label(EDIT_IN_EDITOR).is_none(), "no button"); + python_window::open_from_bar(h.state_mut()); + h.run_steps(3); + assert!(win(&h).is_none(), "the route opens nothing"); + // Without protection the button is there. + let h = hidden_code(false); + assert!(h.query_by_label(EDIT_IN_EDITOR).is_some()); +} + +#[test] +fn the_cell_menu_offers_no_editor_entry_on_hidden_code() { + let mut h = hidden_code(true); + h.state_mut().grid.context_menu = Some(Pos2::new(300.0, 300.0)); + h.run_steps(3); + let menu = h.ctx.memory(|m| m.area_rect(egui::Id::unique("cell_context"))).expect("the cell menu"); + assert!(h.query_all_by_label(EDIT_IN_EDITOR).all(|n| !menu.contains(n.rect().center())), "no entry in the menu"); + python_window::open_from_menu(h.state_mut()); + h.run_steps(3); + assert!(win(&h).is_none(), "the route opens nothing"); +} + +#[test] +fn protecting_the_sheet_closes_the_window_on_hidden_code_and_keeps_typed_code_on_the_clipboard() { + // Without changes, the window just closes. + let mut h = hidden_code(false); + ui(&mut h, json!({"on": true, "cell": "B2"})).unwrap(); + assert_eq!(text(&h), "x = 1\nx + 1"); + h.state_mut().session.execute("review.protectSheet", json!({})).unwrap(); + h.run_steps(3); + assert!(win(&h).is_none(), "the code is out of view"); + assert!(h.query_by_label("x = 1\nx + 1").is_none()); + assert!(h.state().toast.is_none(), "nothing typed, nothing copied"); + // With typed code, it closes too, and the typed code goes to the clipboard. + let mut h = hidden_code(false); + ui(&mut h, json!({"on": true, "cell": "B2"})).unwrap(); + to_end(&mut h); + type_text(&mut h, " # typed"); + assert!(win(&h).is_some_and(|w| w.dirty())); + h.state_mut().session.execute("review.protectSheet", json!({})).unwrap(); + h.step(); + let copied = h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == "x = 1\nx + 1 # typed")); + h.run_steps(2); + assert!(win(&h).is_none(), "the code is out of view"); + assert!(copied, "the typed code is on the clipboard"); + assert_eq!(h.state().toast.as_ref().map(|t| t.0.as_str()), Some(python_window::COPIED)); + assert_eq!(code(&h, "B2").as_deref(), Some("x = 1\nx + 1"), "nothing was applied"); +} + +#[test] +fn apply_then_undo_then_typing_applies_to_the_same_cell() { + let mut h = harness(with_code()); + ui(&mut h, json!({"on": true, "cell": "B2"})).unwrap(); + to_end(&mut h); + type_text(&mut h, " + 2"); + key(&mut h, Key::Enter, Modifiers::COMMAND); + assert_eq!(code(&h, "B2").as_deref(), Some("x = 1\nx + 1 + 2")); + // Undo puts the old code back in the cell; the window, with nothing unsaved, shows it. + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(code(&h, "B2").as_deref(), Some("x = 1\nx + 1")); + assert_eq!(text(&h), "x = 1\nx + 1"); + to_end(&mut h); + type_text(&mut h, " + 3"); + key(&mut h, Key::Enter, Modifiers::COMMAND); + assert_eq!(notice(&h), None); + assert_eq!(code(&h, "B2").as_deref(), Some("x = 1\nx + 1 + 3"), "Apply edits the code the window shows"); + // The same on a cell the window gave its first code: the undo empties it, Apply fills it again. + ui(&mut h, json!({"on": true, "cell": "D4"})).unwrap(); + type_text(&mut h, "y = 1"); + key(&mut h, Key::Enter, Modifiers::COMMAND); + assert_eq!(code(&h, "D4").as_deref(), Some("y = 1")); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!((code(&h, "D4"), text(&h)), (None, String::new())); + type_text(&mut h, "z = 2"); + key(&mut h, Key::Enter, Modifiers::COMMAND); + assert_eq!(notice(&h), None); + assert_eq!(code(&h, "D4").as_deref(), Some("z = 2")); +} + +#[test] +fn after_an_undo_a_row_inserted_above_with_unsaved_typing_still_refuses_apply() { + let mut s = with_code(); + s.execute("cell.set", json!({"cell": "B1", "input": "keep"})).unwrap(); + let mut h = harness(s); + ui(&mut h, json!({"on": true, "cell": "B2"})).unwrap(); + to_end(&mut h); + type_text(&mut h, " + 2"); + key(&mut h, Key::Enter, Modifiers::COMMAND); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + to_end(&mut h); + type_text(&mut h, " + 3"); + h.state_mut().run("home.insertRows", json!({"rows": "1:1"})).unwrap(); + h.run_steps(3); + key(&mut h, Key::Enter, Modifiers::COMMAND); + assert_eq!(input(&h, "B2"), "keep", "the text that moved to B2 is not replaced"); + assert_eq!(code(&h, "B3").as_deref(), Some("x = 1\nx + 1")); + assert_eq!(text(&h), "x = 1\nx + 1 + 3", "the code is kept"); + assert_eq!(notice(&h), Some(stale_message("Sheet1!B2"))); +} + +/// B2 and B3 hold code; A2 and A3 sort them so that B3's row comes first. +fn two_python_cells(b3: &str) -> Harness { + let mut s = with_code(); + s.execute("python.set", json!({"cell": "B3", "code": b3})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "2"})).unwrap(); + s.execute("cell.set", json!({"cell": "A3", "input": "1"})).unwrap(); + harness(s) +} + +#[test] +fn a_sort_that_moves_another_python_cell_to_the_address_refuses_apply() { + let mut h = two_python_cells("b = 3"); + ui(&mut h, json!({"on": true, "cell": "B2"})).unwrap(); + to_end(&mut h); + type_text(&mut h, " + 2"); + h.state_mut().run("data.sortAscending", json!({"range": "A2:B3", "column": "A"})).unwrap(); + h.run_steps(3); + assert_eq!((code(&h, "B2").as_deref(), code(&h, "B3").as_deref()), (Some("b = 3"), Some("x = 1\nx + 1")), "the sort swapped the cells"); + key(&mut h, Key::Enter, Modifiers::COMMAND); + assert_eq!(code(&h, "B2").as_deref(), Some("b = 3"), "the cell that moved in is not replaced"); + assert_eq!(text(&h), "x = 1\nx + 1 + 2"); + assert_eq!(notice(&h), Some(stale_message("Sheet1!B2"))); + // The same when the code that moved in is exactly the unsaved typing: the window takes it as + // saved, but the typing was for the cell that moved away, so further typing is still refused. + let mut h = two_python_cells("x = 1\nx + 1 + 2"); + ui(&mut h, json!({"on": true, "cell": "B2"})).unwrap(); + to_end(&mut h); + type_text(&mut h, " + 2"); + h.state_mut().run("data.sortAscending", json!({"range": "A2:B3", "column": "A"})).unwrap(); + h.run_steps(3); + assert_eq!(code(&h, "B2").as_deref(), Some("x = 1\nx + 1 + 2")); + to_end(&mut h); + type_text(&mut h, " + 4"); + key(&mut h, Key::Enter, Modifiers::COMMAND); + assert_eq!(code(&h, "B2").as_deref(), Some("x = 1\nx + 1 + 2"), "the cell that moved in is not replaced"); + assert_eq!(code(&h, "B3").as_deref(), Some("x = 1\nx + 1")); + assert_eq!(notice(&h), Some(stale_message("Sheet1!B2"))); +} + +#[test] +fn the_question_closes_the_file_view_so_it_can_be_seen() { + let mut h = harness(blank()); + ui(&mut h, json!({"on": true})).unwrap(); + type_text(&mut h, "a = 1"); + h.state_mut().run("ui.backstage", json!({"page": "info"})).unwrap(); + h.run_steps(2); + assert!(h.state().backstage.is_some(), "the File view is open"); + h.state_mut().run_or_alert("file.close", json!({})); + h.run_steps(3); + assert!(matches!(win(&h).and_then(|w| w.pending), Some(Pending::CloseWorkbook(_))), "it asks"); + assert!(h.state().backstage.is_none(), "the File view closes, so the question is in sight"); +} From 6bf272373d444007f3e01aa4b77ee60e7a28e8ab Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:48:48 -0400 Subject: [PATCH 3/4] i18n: the Python editor's strings in every catalog The window's title, Edit in Python Editor (the formula bar button and the cell menu), Unsaved changes, the questions about unsaved code (with and without the workbook's name), the messages for code past the limit, for a cell that changed while it was open (before Apply, and when Apply is refused) and for code copied to the clipboard, the keys under the code, and Apply, Discard and Cancel where a catalog lacked them, in all 18 interface catalogs. Placeholders ({cell}, {workbook}, {max}, {apply}) are kept in each. --- crates/ui-egui/src/i18n/cs.rs | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/de.rs | 21 +++++++++++++++++++++ crates/ui-egui/src/i18n/el.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/i18n/es.rs | 19 +++++++++++++++++++ crates/ui-egui/src/i18n/fr.rs | 21 +++++++++++++++++++++ crates/ui-egui/src/i18n/it.rs | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/ja.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/i18n/ko.rs | 19 +++++++++++++++++++ crates/ui-egui/src/i18n/nl.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/i18n/pl.rs | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/pt.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/i18n/ro.rs | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/ru.rs | 19 +++++++++++++++++++ crates/ui-egui/src/i18n/sr.rs | 19 +++++++++++++++++++ crates/ui-egui/src/i18n/tr.rs | 19 +++++++++++++++++++ crates/ui-egui/src/i18n/uk.rs | 18 ++++++++++++++++++ crates/ui-egui/src/i18n/vi.rs | 19 +++++++++++++++++++ crates/ui-egui/src/i18n/zh.rs | 19 +++++++++++++++++++ 18 files changed, 345 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index c117811731..9032ea6534 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -150,6 +150,24 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "This version of GridCraft stores Python code but does not run it." => "Tato verze GridCraftu ukládá kód Pythonu, ale nespouští ho.", "Insert\nPython" => "Vložit\nPython", "Insert Python (Ctrl+Alt+Shift+P)" => "Vložit Python (Ctrl+Alt+Shift+P)", + "Python Editor" => "Editor Pythonu", + "Edit in Python Editor" => "Upravit v editoru Pythonu", + "Unsaved changes" => "Neuložené změny", + "The code for {cell} has unsaved changes." => "Kód pro {cell} obsahuje neuložené změny.", + "The code for {cell} in {workbook} has unsaved changes." => "Kód pro {cell} v sešitu {workbook} obsahuje neuložené změny.", + "The code can't be longer: a Python cell holds {max} characters, counted as a PY formula." => { + "Kód nemůže být delší: buňka Pythonu pojme {max} znaků počítaných jako vzorec PY." + } + "{cell} changed while it was open in the Python editor. Copy the code, or close the editor and open it again on the cell." => { + "Buňka {cell} se změnila, zatímco byla otevřená v editoru Pythonu. Zkopírujte kód, nebo editor zavřete a otevřete ho na buňce znovu." + } + "{cell} changed while it was open in the Python editor, so the code was not applied. Copy the code, or close the editor and open it again on the cell." => { + "Buňka {cell} se změnila, zatímco byla otevřená v editoru Pythonu, takže kód nebyl použit. Zkopírujte kód, nebo editor zavřete a otevřete ho na buňce znovu." + } + "{apply} applies the code. Esc closes the editor." => "{apply} použije kód. Esc zavře editor.", + "The Python editor's unsaved code was copied to the clipboard." => "Neuložený kód z editoru Pythonu byl zkopírován do schránky.", + "Apply" => "Použít", + "Discard" => "Zahodit", "Evaluate Formula" => "Vyhodnotit vzorec", "Watch Window" => "Okno sledování", "Calculation\nOptions" => "Možnosti\nvýpočtu", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index de0949d86d..4b416b8c53 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -156,6 +156,27 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { } "Insert\nPython" => "Python\neinfügen", "Insert Python (Ctrl+Alt+Shift+P)" => "Python einfügen (Ctrl+Alt+Shift+P)", + "Python Editor" => "Python-Editor", + "Edit in Python Editor" => "Im Python-Editor bearbeiten", + "Unsaved changes" => "Nicht gespeicherte Änderungen", + "The code for {cell} has unsaved changes." => "Der Code für {cell} hat nicht gespeicherte Änderungen.", + "The code for {cell} in {workbook} has unsaved changes." => "Der Code für {cell} in {workbook} hat nicht gespeicherte Änderungen.", + "The code can't be longer: a Python cell holds {max} characters, counted as a PY formula." => { + "Der Code kann nicht länger werden: Eine Python-Zelle fasst {max} Zeichen, als PY-Formel gezählt." + } + "{cell} changed while it was open in the Python editor. Copy the code, or close the editor and open it again on the cell." => { + "{cell} hat sich geändert, während die Zelle im Python-Editor geöffnet war. Kopieren Sie den Code, oder schließen Sie den Editor und öffnen Sie ihn erneut für die Zelle." + } + "{cell} changed while it was open in the Python editor, so the code was not applied. Copy the code, or close the editor and open it again on the cell." => { + "{cell} hat sich geändert, während die Zelle im Python-Editor geöffnet war, daher wurde der Code nicht übernommen. Kopieren Sie den Code, oder schließen Sie den Editor und öffnen Sie ihn erneut für die Zelle." + } + "{apply} applies the code. Esc closes the editor." => "{apply} übernimmt den Code. Esc schließt den Editor.", + "The Python editor's unsaved code was copied to the clipboard." => { + "Der nicht gespeicherte Code des Python-Editors wurde in die Zwischenablage kopiert." + } + "Apply" => "Übernehmen", + "Discard" => "Verwerfen", + "Cancel" => "Abbrechen", "Evaluate Formula" => "Formel auswerten", "Watch Window" => "Überwachungsfenster", "Calculation\nOptions" => "Berechnungs-\noptionen", diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index 3f397ca3dc..68c3225372 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -151,6 +151,26 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { } "Insert\nPython" => "Εισαγωγή\nPython", "Insert Python (Ctrl+Alt+Shift+P)" => "Εισαγωγή Python (Ctrl+Alt+Shift+P)", + "Python Editor" => "Πρόγραμμα επεξεργασίας Python", + "Edit in Python Editor" => "Επεξεργασία στο πρόγραμμα επεξεργασίας Python", + "Unsaved changes" => "Μη αποθηκευμένες αλλαγές", + "The code for {cell} has unsaved changes." => "Ο κώδικας για το {cell} έχει μη αποθηκευμένες αλλαγές.", + "The code for {cell} in {workbook} has unsaved changes." => "Ο κώδικας για το {cell} στο {workbook} έχει μη αποθηκευμένες αλλαγές.", + "The code can't be longer: a Python cell holds {max} characters, counted as a PY formula." => { + "Ο κώδικας δεν μπορεί να γίνει μεγαλύτερος: ένα κελί Python χωρά {max} χαρακτήρες, μετρημένους ως τύπος PY." + } + "{cell} changed while it was open in the Python editor. Copy the code, or close the editor and open it again on the cell." => { + "Το κελί {cell} άλλαξε ενώ ήταν ανοιχτό στο πρόγραμμα επεξεργασίας Python. Αντιγράψτε τον κώδικα ή κλείστε το πρόγραμμα επεξεργασίας και ανοίξτε το ξανά στο κελί." + } + "{cell} changed while it was open in the Python editor, so the code was not applied. Copy the code, or close the editor and open it again on the cell." => { + "Το κελί {cell} άλλαξε ενώ ήταν ανοιχτό στο πρόγραμμα επεξεργασίας Python, επομένως ο κώδικας δεν εφαρμόστηκε. Αντιγράψτε τον κώδικα ή κλείστε το πρόγραμμα επεξεργασίας και ανοίξτε το ξανά στο κελί." + } + "{apply} applies the code. Esc closes the editor." => "Το {apply} εφαρμόζει τον κώδικα. Το Esc κλείνει το πρόγραμμα επεξεργασίας.", + "The Python editor's unsaved code was copied to the clipboard." => { + "Ο μη αποθηκευμένος κώδικας του προγράμματος επεξεργασίας Python αντιγράφηκε στο πρόχειρο." + } + "Apply" => "Εφαρμογή", + "Discard" => "Απόρριψη", "Evaluate Formula" => "Αξιολόγηση τύπου", "Watch Window" => "Παράθυρο παρακολούθησης", "Calculation\nOptions" => "Επιλογές\nυπολογισμού", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 78ec9dc5e2..d45467baa3 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -155,6 +155,25 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { } "Insert\nPython" => "Insertar\nPython", "Insert Python (Ctrl+Alt+Shift+P)" => "Insertar Python (Ctrl+Alt+Shift+P)", + "Python Editor" => "Editor de Python", + "Edit in Python Editor" => "Editar en el editor de Python", + "Unsaved changes" => "Cambios sin guardar", + "The code for {cell} has unsaved changes." => "El código de {cell} tiene cambios sin guardar.", + "The code for {cell} in {workbook} has unsaved changes." => "El código de {cell} en {workbook} tiene cambios sin guardar.", + "The code can't be longer: a Python cell holds {max} characters, counted as a PY formula." => { + "El código no puede ser más largo: una celda de Python admite {max} caracteres, contados como fórmula PY." + } + "{cell} changed while it was open in the Python editor. Copy the code, or close the editor and open it again on the cell." => { + "{cell} cambió mientras estaba abierta en el editor de Python. Copie el código, o cierre el editor y vuelva a abrirlo en la celda." + } + "{cell} changed while it was open in the Python editor, so the code was not applied. Copy the code, or close the editor and open it again on the cell." => { + "{cell} cambió mientras estaba abierta en el editor de Python, así que el código no se aplicó. Copie el código, o cierre el editor y vuelva a abrirlo en la celda." + } + "{apply} applies the code. Esc closes the editor." => "{apply} aplica el código. Esc cierra el editor.", + "The Python editor's unsaved code was copied to the clipboard." => "El código sin guardar del editor de Python se copió en el portapapeles.", + "Apply" => "Aplicar", + "Discard" => "Descartar", + "Cancel" => "Cancelar", "Evaluate Formula" => "Evaluar fórmula", "Watch Window" => "Ventana Inspección", "Calculation\nOptions" => "Opciones\nde cálculo", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index e96b600505..3c0740a29a 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -166,6 +166,27 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { } "Insert\nPython" => "Insérer\nPython", "Insert Python (Ctrl+Alt+Shift+P)" => "Insérer Python (Ctrl+Alt+Shift+P)", + "Python Editor" => "Éditeur Python", + "Edit in Python Editor" => "Modifier dans l'éditeur Python", + "Unsaved changes" => "Modifications non enregistrées", + "The code for {cell} has unsaved changes." => "Le code de {cell} contient des modifications non enregistrées.", + "The code for {cell} in {workbook} has unsaved changes." => "Le code de {cell} dans {workbook} contient des modifications non enregistrées.", + "The code can't be longer: a Python cell holds {max} characters, counted as a PY formula." => { + "Le code ne peut pas être plus long : une cellule Python contient {max} caractères, comptés comme formule PY." + } + "{cell} changed while it was open in the Python editor. Copy the code, or close the editor and open it again on the cell." => { + "{cell} a changé pendant son ouverture dans l'éditeur Python. Copiez le code, ou fermez l'éditeur et rouvrez-le sur la cellule." + } + "{cell} changed while it was open in the Python editor, so the code was not applied. Copy the code, or close the editor and open it again on the cell." => { + "{cell} a changé pendant son ouverture dans l'éditeur Python, le code n'a donc pas été appliqué. Copiez le code, ou fermez l'éditeur et rouvrez-le sur la cellule." + } + "{apply} applies the code. Esc closes the editor." => "{apply} applique le code. Échap ferme l'éditeur.", + "The Python editor's unsaved code was copied to the clipboard." => { + "Le code non enregistré de l'éditeur Python a été copié dans le Presse-papiers." + } + "Apply" => "Appliquer", + "Discard" => "Ignorer", + "Cancel" => "Annuler", "Evaluate Formula" => "Évaluation de formule", "Watch Window" => "Fenêtre Espion", "Calculation\nOptions" => "Options de\ncalcul", diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index bc281cb17b..09976153d0 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -159,6 +159,24 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { } "Insert\nPython" => "Inserisci\nPython", "Insert Python (Ctrl+Alt+Shift+P)" => "Inserisci Python (Ctrl+Alt+Shift+P)", + "Python Editor" => "Editor Python", + "Edit in Python Editor" => "Modifica nell'editor Python", + "Unsaved changes" => "Modifiche non salvate", + "The code for {cell} has unsaved changes." => "Il codice di {cell} ha modifiche non salvate.", + "The code for {cell} in {workbook} has unsaved changes." => "Il codice di {cell} in {workbook} ha modifiche non salvate.", + "The code can't be longer: a Python cell holds {max} characters, counted as a PY formula." => { + "Il codice non può essere più lungo: una cella Python contiene {max} caratteri, contati come formula PY." + } + "{cell} changed while it was open in the Python editor. Copy the code, or close the editor and open it again on the cell." => { + "{cell} è cambiata mentre era aperta nell'editor Python. Copia il codice, oppure chiudi l'editor e riaprilo sulla cella." + } + "{cell} changed while it was open in the Python editor, so the code was not applied. Copy the code, or close the editor and open it again on the cell." => { + "{cell} è cambiata mentre era aperta nell'editor Python, quindi il codice non è stato applicato. Copia il codice, oppure chiudi l'editor e riaprilo sulla cella." + } + "{apply} applies the code. Esc closes the editor." => "{apply} applica il codice. Esc chiude l'editor.", + "The Python editor's unsaved code was copied to the clipboard." => "Il codice non salvato dell'editor Python è stato copiato negli Appunti.", + "Apply" => "Applica", + "Discard" => "Ignora", "Evaluate Formula" => "Valuta formula", "Watch Window" => "Finestra controlli", "Calculation\nOptions" => "Opzioni di\ncalcolo", diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 33323fc590..4e617b3f22 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -164,6 +164,26 @@ pub(super) const CORE: &[(&str, &str)] = &[ ), ("Insert\nPython", "Python の\n挿入"), ("Insert Python (Ctrl+Alt+Shift+P)", "Python の挿入 (Ctrl+Alt+Shift+P)"), + ("Python Editor", "Python エディター"), + ("Edit in Python Editor", "Python エディターで編集"), + ("Unsaved changes", "未保存の変更"), + ("The code for {cell} has unsaved changes.", "{cell} のコードに未保存の変更があります。"), + ("The code for {cell} in {workbook} has unsaved changes.", "{cell} ({workbook}) のコードに未保存の変更があります。"), + ( + "The code can't be longer: a Python cell holds {max} characters, counted as a PY formula.", + "コードをこれ以上長くできません。Python セルに入るのは PY 数式として数えて {max} 文字までです。", + ), + ( + "{cell} changed while it was open in the Python editor. Copy the code, or close the editor and open it again on the cell.", + "{cell} は Python エディターで開いている間に変更されました。コードをコピーするか、エディターを閉じてセルでもう一度開いてください。", + ), + ( + "{cell} changed while it was open in the Python editor, so the code was not applied. Copy the code, or close the editor and open it again on the cell.", + "{cell} は Python エディターで開いている間に変更されました。そのため、コードは適用されませんでした。コードをコピーするか、エディターを閉じてセルでもう一度開いてください。", + ), + ("{apply} applies the code. Esc closes the editor.", "{apply} でコードを適用します。Esc でエディターを閉じます。"), + ("The Python editor's unsaved code was copied to the clipboard.", "Python エディターの未保存のコードをクリップボードにコピーしました。"), + ("Discard", "破棄"), ("Evaluate Formula", "数式の検証"), ("Watch Window", "ウォッチ ウィンドウ"), ("Calculation\nOptions", "計算方法の設定"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 8fcc5a28a4..176ced87e8 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -159,6 +159,25 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "This version of GridCraft stores Python code but does not run it." => "이 버전의 GridCraft는 Python 코드를 저장하지만 실행하지는 않습니다.", "Insert\nPython" => "Python\n삽입", "Insert Python (Ctrl+Alt+Shift+P)" => "Python 삽입 (Ctrl+Alt+Shift+P)", + "Python Editor" => "Python 편집기", + "Edit in Python Editor" => "Python 편집기에서 편집", + "Unsaved changes" => "저장하지 않은 변경 내용", + "The code for {cell} has unsaved changes." => "{cell}의 코드에 저장하지 않은 변경 내용이 있습니다.", + "The code for {cell} in {workbook} has unsaved changes." => "{cell}({workbook})의 코드에 저장하지 않은 변경 내용이 있습니다.", + "The code can't be longer: a Python cell holds {max} characters, counted as a PY formula." => { + "코드를 더 길게 할 수 없습니다. Python 셀에는 PY 수식으로 계산하여 {max}자까지 들어갑니다." + } + "{cell} changed while it was open in the Python editor. Copy the code, or close the editor and open it again on the cell." => { + "{cell}이(가) Python 편집기에서 열려 있는 동안 변경되었습니다. 코드를 복사하거나, 편집기를 닫고 셀에서 다시 여세요." + } + "{cell} changed while it was open in the Python editor, so the code was not applied. Copy the code, or close the editor and open it again on the cell." => { + "{cell}이(가) Python 편집기에서 열려 있는 동안 변경되었습니다. 따라서 코드가 적용되지 않았습니다. 코드를 복사하거나, 편집기를 닫고 셀에서 다시 여세요." + } + "{apply} applies the code. Esc closes the editor." => "{apply}로 코드를 적용합니다. Esc로 편집기를 닫습니다.", + "The Python editor's unsaved code was copied to the clipboard." => "Python 편집기의 저장하지 않은 코드를 클립보드에 복사했습니다.", + "Apply" => "적용", + "Discard" => "버리기", + "Cancel" => "취소", "Evaluate Formula" => "수식 계산", "Watch Window" => "감시 창", "Calculation\nOptions" => "계산\n옵션", diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index 16dc4acc49..5ef5cbd2ef 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -160,6 +160,26 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { } "Insert\nPython" => "Python\ninvoegen", "Insert Python (Ctrl+Alt+Shift+P)" => "Python invoegen (Ctrl+Alt+Shift+P)", + "Python Editor" => "Python-editor", + "Edit in Python Editor" => "Bewerken in Python-editor", + "Unsaved changes" => "Niet-opgeslagen wijzigingen", + "The code for {cell} has unsaved changes." => "De code voor {cell} heeft niet-opgeslagen wijzigingen.", + "The code for {cell} in {workbook} has unsaved changes." => "De code voor {cell} in {workbook} heeft niet-opgeslagen wijzigingen.", + "The code can't be longer: a Python cell holds {max} characters, counted as a PY formula." => { + "De code kan niet langer worden: een Python-cel bevat {max} tekens, geteld als PY-formule." + } + "{cell} changed while it was open in the Python editor. Copy the code, or close the editor and open it again on the cell." => { + "{cell} is gewijzigd terwijl de cel in de Python-editor geopend was. Kopieer de code, of sluit de editor en open deze opnieuw op de cel." + } + "{cell} changed while it was open in the Python editor, so the code was not applied. Copy the code, or close the editor and open it again on the cell." => { + "{cell} is gewijzigd terwijl de cel in de Python-editor geopend was, dus de code is niet toegepast. Kopieer de code, of sluit de editor en open deze opnieuw op de cel." + } + "{apply} applies the code. Esc closes the editor." => "{apply} past de code toe. Esc sluit de editor.", + "The Python editor's unsaved code was copied to the clipboard." => { + "De niet-opgeslagen code van de Python-editor is naar het klembord gekopieerd." + } + "Apply" => "Toepassen", + "Discard" => "Negeren", "Evaluate Formula" => "Formule evalueren", "Watch Window" => "Bewakingsvenster", "Calculation\nOptions" => "Berekenings-\nopties", diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index ba9f668a51..f1d26d9c32 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -155,6 +155,24 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { } "Insert\nPython" => "Wstaw\nPython", "Insert Python (Ctrl+Alt+Shift+P)" => "Wstaw Python (Ctrl+Alt+Shift+P)", + "Python Editor" => "Edytor języka Python", + "Edit in Python Editor" => "Edytuj w edytorze języka Python", + "Unsaved changes" => "Niezapisane zmiany", + "The code for {cell} has unsaved changes." => "Kod komórki {cell} ma niezapisane zmiany.", + "The code for {cell} in {workbook} has unsaved changes." => "Kod komórki {cell} w skoroszycie {workbook} ma niezapisane zmiany.", + "The code can't be longer: a Python cell holds {max} characters, counted as a PY formula." => { + "Kod nie może być dłuższy: komórka języka Python mieści {max} znaków liczonych jako formuła PY." + } + "{cell} changed while it was open in the Python editor. Copy the code, or close the editor and open it again on the cell." => { + "Komórka {cell} zmieniła się, gdy była otwarta w edytorze języka Python. Skopiuj kod albo zamknij edytor i otwórz go ponownie na komórce." + } + "{cell} changed while it was open in the Python editor, so the code was not applied. Copy the code, or close the editor and open it again on the cell." => { + "Komórka {cell} zmieniła się, gdy była otwarta w edytorze języka Python, więc kod nie został zastosowany. Skopiuj kod albo zamknij edytor i otwórz go ponownie na komórce." + } + "{apply} applies the code. Esc closes the editor." => "{apply} stosuje kod. Esc zamyka edytor.", + "The Python editor's unsaved code was copied to the clipboard." => "Niezapisany kod edytora języka Python skopiowano do schowka.", + "Apply" => "Zastosuj", + "Discard" => "Odrzuć", "Evaluate Formula" => "Szacuj formułę", "Watch Window" => "Okno czujki", "Calculation\nOptions" => "Opcje\nobliczania", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index a41ebbe21f..09086d6080 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -158,6 +158,26 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { } "Insert\nPython" => "Inserir\nPython", "Insert Python (Ctrl+Alt+Shift+P)" => "Inserir Python (Ctrl+Alt+Shift+P)", + "Python Editor" => "Editor do Python", + "Edit in Python Editor" => "Editar no Editor do Python", + "Unsaved changes" => "Alterações não salvas", + "The code for {cell} has unsaved changes." => "O código de {cell} tem alterações não salvas.", + "The code for {cell} in {workbook} has unsaved changes." => "O código de {cell} em {workbook} tem alterações não salvas.", + "The code can't be longer: a Python cell holds {max} characters, counted as a PY formula." => { + "O código não pode ficar maior: uma célula do Python comporta {max} caracteres, contados como fórmula PY." + } + "{cell} changed while it was open in the Python editor. Copy the code, or close the editor and open it again on the cell." => { + "{cell} mudou enquanto estava aberta no Editor do Python. Copie o código ou feche o editor e abra-o novamente na célula." + } + "{cell} changed while it was open in the Python editor, so the code was not applied. Copy the code, or close the editor and open it again on the cell." => { + "{cell} mudou enquanto estava aberta no Editor do Python, por isso o código não foi aplicado. Copie o código ou feche o editor e abra-o novamente na célula." + } + "{apply} applies the code. Esc closes the editor." => "{apply} aplica o código. Esc fecha o editor.", + "The Python editor's unsaved code was copied to the clipboard." => { + "O código não salvo do Editor do Python foi copiado para a área de transferência." + } + "Apply" => "Aplicar", + "Discard" => "Descartar", "Evaluate Formula" => "Avaliar Fórmula", "Watch Window" => "Janela de Inspeção", "Calculation\nOptions" => "Opções de\nCálculo", diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index 0d35c1ec65..2713d2a5b0 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -162,6 +162,24 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { } "Insert\nPython" => "Inserare\nPython", "Insert Python (Ctrl+Alt+Shift+P)" => "Inserare Python (Ctrl+Alt+Shift+P)", + "Python Editor" => "Editor Python", + "Edit in Python Editor" => "Editare în editorul Python", + "Unsaved changes" => "Modificări nesalvate", + "The code for {cell} has unsaved changes." => "Codul pentru {cell} are modificări nesalvate.", + "The code for {cell} in {workbook} has unsaved changes." => "Codul pentru {cell} din {workbook} are modificări nesalvate.", + "The code can't be longer: a Python cell holds {max} characters, counted as a PY formula." => { + "Codul nu poate fi mai lung: o celulă Python conține {max} caractere, numărate ca formulă PY." + } + "{cell} changed while it was open in the Python editor. Copy the code, or close the editor and open it again on the cell." => { + "Celula {cell} s-a modificat cât timp era deschisă în editorul Python. Copiați codul sau închideți editorul și deschideți-l din nou pe celulă." + } + "{cell} changed while it was open in the Python editor, so the code was not applied. Copy the code, or close the editor and open it again on the cell." => { + "Celula {cell} s-a modificat cât timp era deschisă în editorul Python, așa că codul nu a fost aplicat. Copiați codul sau închideți editorul și deschideți-l din nou pe celulă." + } + "{apply} applies the code. Esc closes the editor." => "{apply} aplică codul. Esc închide editorul.", + "The Python editor's unsaved code was copied to the clipboard." => "Codul nesalvat din editorul Python a fost copiat în clipboard.", + "Apply" => "Aplicare", + "Discard" => "Renunțare", "Evaluate Formula" => "Evaluare formulă", "Watch Window" => "Fereastră de urmărire", "Calculation\nOptions" => "Opțiuni de\ncalcul", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index d032a782fa..59840bd583 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -159,6 +159,25 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "This version of GridCraft stores Python code but does not run it." => "Эта версия GridCraft хранит код Python, но не выполняет его.", "Insert\nPython" => "Вставить\nPython", "Insert Python (Ctrl+Alt+Shift+P)" => "Вставить Python (Ctrl+Alt+Shift+P)", + "Python Editor" => "Редактор Python", + "Edit in Python Editor" => "Изменить в редакторе Python", + "Unsaved changes" => "Несохраненные изменения", + "The code for {cell} has unsaved changes." => "В коде для {cell} есть несохраненные изменения.", + "The code for {cell} in {workbook} has unsaved changes." => "В коде для {cell} в книге {workbook} есть несохраненные изменения.", + "The code can't be longer: a Python cell holds {max} characters, counted as a PY formula." => { + "Код не может быть длиннее: ячейка Python вмещает {max} символов в виде формулы PY." + } + "{cell} changed while it was open in the Python editor. Copy the code, or close the editor and open it again on the cell." => { + "Ячейка {cell} изменилась, пока была открыта в редакторе Python. Скопируйте код или закройте редактор и снова откройте его для ячейки." + } + "{cell} changed while it was open in the Python editor, so the code was not applied. Copy the code, or close the editor and open it again on the cell." => { + "Ячейка {cell} изменилась, пока была открыта в редакторе Python, поэтому код не применен. Скопируйте код или закройте редактор и снова откройте его для ячейки." + } + "{apply} applies the code. Esc closes the editor." => "{apply} применяет код. Esc закрывает редактор.", + "The Python editor's unsaved code was copied to the clipboard." => "Несохраненный код редактора Python скопирован в буфер обмена.", + "Apply" => "Применить", + "Discard" => "Отклонить", + "Cancel" => "Отмена", "Evaluate Formula" => "Вычислить формулу", "Watch Window" => "Окно контрольных значений", "Calculation\nOptions" => "Параметры\nвычислений", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index af53324dc9..4b7141c73c 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -154,6 +154,25 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "This version of GridCraft stores Python code but does not run it." => "Ova verzija programa GridCraft čuva Python kôd, ali ga ne pokreće.", "Insert\nPython" => "Umetni\nPython", "Insert Python (Ctrl+Alt+Shift+P)" => "Umetni Python (Ctrl+Alt+Shift+P)", + "Python Editor" => "Python uređivač", + "Edit in Python Editor" => "Uredi u Python uređivaču", + "Unsaved changes" => "Nesačuvane promene", + "The code for {cell} has unsaved changes." => "Kod za {cell} ima nesačuvane promene.", + "The code for {cell} in {workbook} has unsaved changes." => "Kod za {cell} u {workbook} ima nesačuvane promene.", + "The code can't be longer: a Python cell holds {max} characters, counted as a PY formula." => { + "Kod ne može biti duži: Python ćelija prima {max} znakova, računato kao PY formula." + } + "{cell} changed while it was open in the Python editor. Copy the code, or close the editor and open it again on the cell." => { + "Ćelija {cell} se promenila dok je bila otvorena u Python uređivaču. Kopirajte kod ili zatvorite uređivač i ponovo ga otvorite na ćeliji." + } + "{cell} changed while it was open in the Python editor, so the code was not applied. Copy the code, or close the editor and open it again on the cell." => { + "Ćelija {cell} se promenila dok je bila otvorena u Python uređivaču, pa kod nije primenjen. Kopirajte kod ili zatvorite uređivač i ponovo ga otvorite na ćeliji." + } + "{apply} applies the code. Esc closes the editor." => "{apply} primenjuje kod. Esc zatvara uređivač.", + "The Python editor's unsaved code was copied to the clipboard." => "Nesačuvani kod iz Python uređivača kopiran je u ostavu.", + "Apply" => "Primeni", + "Discard" => "Odbaci", + "Cancel" => "Otkaži", "Evaluate Formula" => "Proceni formulu", "Watch Window" => "Prozor za praćenje", "Calculation\nOptions" => "Opcije\nizračunavanja", diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index 835f6310ca..311f59047b 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -152,6 +152,25 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "This version of GridCraft stores Python code but does not run it." => "GridCraft'ın bu sürümü Python kodunu saklar ancak çalıştırmaz.", "Insert\nPython" => "Python\nEkle", "Insert Python (Ctrl+Alt+Shift+P)" => "Python Ekle (Ctrl+Alt+Shift+P)", + "Python Editor" => "Python Düzenleyicisi", + "Edit in Python Editor" => "Python Düzenleyicisinde Düzenle", + "Unsaved changes" => "Kaydedilmemiş değişiklikler", + "The code for {cell} has unsaved changes." => "{cell} kodunda kaydedilmemiş değişiklikler var.", + "The code for {cell} in {workbook} has unsaved changes." => "{cell} ({workbook}) kodunda kaydedilmemiş değişiklikler var.", + "The code can't be longer: a Python cell holds {max} characters, counted as a PY formula." => { + "Kod daha uzun olamaz: bir Python hücresi, PY formülü olarak sayılan {max} karakter tutar." + } + "{cell} changed while it was open in the Python editor. Copy the code, or close the editor and open it again on the cell." => { + "{cell}, Python Düzenleyicisi'nde açıkken değişti. Kodu kopyalayın veya düzenleyiciyi kapatıp hücrede yeniden açın." + } + "{cell} changed while it was open in the Python editor, so the code was not applied. Copy the code, or close the editor and open it again on the cell." => { + "{cell}, Python Düzenleyicisi'nde açıkken değişti. Bu nedenle kod uygulanmadı. Kodu kopyalayın veya düzenleyiciyi kapatıp hücrede yeniden açın." + } + "{apply} applies the code. Esc closes the editor." => "{apply} kodu uygular. Esc düzenleyiciyi kapatır.", + "The Python editor's unsaved code was copied to the clipboard." => "Python düzenleyicisinin kaydedilmemiş kodu panoya kopyalandı.", + "Apply" => "Uygula", + "Discard" => "At", + "Cancel" => "İptal", "Evaluate Formula" => "Formülü Değerlendir", "Watch Window" => "Gözcü Penceresi", "Calculation\nOptions" => "Hesaplama\nSeçenekleri", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 989ad21946..90eb1354b9 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -155,6 +155,24 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "This version of GridCraft stores Python code but does not run it." => "Ця версія GridCraft зберігає код Python, але не виконує його.", "Insert\nPython" => "Вставити\nPython", "Insert Python (Ctrl+Alt+Shift+P)" => "Вставити Python (Ctrl+Alt+Shift+P)", + "Python Editor" => "Редактор Python", + "Edit in Python Editor" => "Редагувати в редакторі Python", + "Unsaved changes" => "Незбережені зміни", + "The code for {cell} has unsaved changes." => "Код для {cell} має незбережені зміни.", + "The code for {cell} in {workbook} has unsaved changes." => "Код для {cell} у книзі {workbook} має незбережені зміни.", + "The code can't be longer: a Python cell holds {max} characters, counted as a PY formula." => { + "Код не може бути довшим: клітинка Python вміщує {max} символів у вигляді формули PY." + } + "{cell} changed while it was open in the Python editor. Copy the code, or close the editor and open it again on the cell." => { + "Клітинка {cell} змінилася, поки була відкрита в редакторі Python. Скопіюйте код або закрийте редактор і знову відкрийте його для клітинки." + } + "{cell} changed while it was open in the Python editor, so the code was not applied. Copy the code, or close the editor and open it again on the cell." => { + "Клітинка {cell} змінилася, поки була відкрита в редакторі Python, тому код не застосовано. Скопіюйте код або закрийте редактор і знову відкрийте його для клітинки." + } + "{apply} applies the code. Esc closes the editor." => "{apply} застосовує код. Esc закриває редактор.", + "The Python editor's unsaved code was copied to the clipboard." => "Незбережений код редактора Python скопійовано в буфер обміну.", + "Apply" => "Застосувати", + "Discard" => "Відхилити", "Evaluate Formula" => "Обчислити формулу", "Watch Window" => "Окно контрольних значень", "Calculation\nOptions" => "Параметри\nобчислень", diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index 8c119e4a5d..bfae15e07d 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -161,6 +161,25 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "This version of GridCraft stores Python code but does not run it." => "Phiên bản GridCraft này lưu mã Python nhưng không chạy mã.", "Insert\nPython" => "Chèn\nPython", "Insert Python (Ctrl+Alt+Shift+P)" => "Chèn Python (Ctrl+Alt+Shift+P)", + "Python Editor" => "Trình soạn thảo Python", + "Edit in Python Editor" => "Sửa trong Trình soạn thảo Python", + "Unsaved changes" => "Thay đổi chưa lưu", + "The code for {cell} has unsaved changes." => "Mã cho {cell} có thay đổi chưa lưu.", + "The code for {cell} in {workbook} has unsaved changes." => "Mã cho {cell} trong {workbook} có thay đổi chưa lưu.", + "The code can't be longer: a Python cell holds {max} characters, counted as a PY formula." => { + "Mã không thể dài hơn: một ô Python chứa {max} ký tự, tính theo công thức PY." + } + "{cell} changed while it was open in the Python editor. Copy the code, or close the editor and open it again on the cell." => { + "{cell} đã thay đổi trong khi đang mở trong Trình soạn thảo Python. Hãy sao chép mã, hoặc đóng trình soạn thảo rồi mở lại trên ô." + } + "{cell} changed while it was open in the Python editor, so the code was not applied. Copy the code, or close the editor and open it again on the cell." => { + "{cell} đã thay đổi trong khi đang mở trong Trình soạn thảo Python, vì vậy mã chưa được áp dụng. Hãy sao chép mã, hoặc đóng trình soạn thảo rồi mở lại trên ô." + } + "{apply} applies the code. Esc closes the editor." => "{apply} áp dụng mã. Esc đóng trình soạn thảo.", + "The Python editor's unsaved code was copied to the clipboard." => "Mã chưa lưu của Trình soạn thảo Python đã được sao chép vào bảng tạm.", + "Apply" => "Áp dụng", + "Discard" => "Bỏ", + "Cancel" => "Hủy", "Evaluate Formula" => "Đánh giá công thức", "Watch Window" => "Cửa sổ theo dõi", "Calculation\nOptions" => "Tùy chọn\ntính toán", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 2c4a6662cd..e35874bc1c 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -159,6 +159,25 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "This version of GridCraft stores Python code but does not run it." => "此版本的 GridCraft 会保存 Python 代码,但不会运行它。", "Insert\nPython" => "插入\nPython", "Insert Python (Ctrl+Alt+Shift+P)" => "插入 Python (Ctrl+Alt+Shift+P)", + "Python Editor" => "Python 编辑器", + "Edit in Python Editor" => "在 Python 编辑器中编辑", + "Unsaved changes" => "未保存的更改", + "The code for {cell} has unsaved changes." => "{cell} 的代码有未保存的更改。", + "The code for {cell} in {workbook} has unsaved changes." => "{cell}(位于 {workbook})的代码有未保存的更改。", + "The code can't be longer: a Python cell holds {max} characters, counted as a PY formula." => { + "代码不能再长了:Python 单元格最多容纳 {max} 个字符(按 PY 公式计算)。" + } + "{cell} changed while it was open in the Python editor. Copy the code, or close the editor and open it again on the cell." => { + "{cell} 在 Python 编辑器中打开期间已更改。请复制代码,或关闭编辑器并在该单元格上重新打开。" + } + "{cell} changed while it was open in the Python editor, so the code was not applied. Copy the code, or close the editor and open it again on the cell." => { + "{cell} 在 Python 编辑器中打开期间已更改。因此未应用代码。请复制代码,或关闭编辑器并在该单元格上重新打开。" + } + "{apply} applies the code. Esc closes the editor." => "{apply} 应用代码。Esc 关闭编辑器。", + "The Python editor's unsaved code was copied to the clipboard." => "Python 编辑器中未保存的代码已复制到剪贴板。", + "Apply" => "应用", + "Discard" => "放弃", + "Cancel" => "取消", "Evaluate Formula" => "公式求值", "Watch Window" => "监视窗口", "Calculation\nOptions" => "计算\n选项", From 2b763a40554da422e1d4222fbf2543678d4f333b Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:48:48 -0400 Subject: [PATCH 4/4] Docs: the Python editor window (UI parity, functions, architecture, control protocol, roadmap) ui-parity.md gains a Python editor window row: its routes, line numbers, Apply and Ctrl+Enter through python.set, the questions before unsaved code is lost, the limit applied while typing and pasting, and the 600x400 fit; no error underline or output pane, since this build runs no code. function-parity.md's Python in cells row names the window. architecture.md describes python_window.rs, the code field's gutter and accept check, and the questions as window state. control-protocol.md lists ui.pythonEditor with force, the file.close refusal, and pythonEditor in ui.inspect. ROADMAP.md records the progress. --- ROADMAP.md | 4 +++- docs/architecture.md | 13 +++++++++++-- docs/control-protocol.md | 5 +++-- docs/function-parity.md | 5 +++-- docs/ui-parity.md | 4 +++- 5 files changed, 23 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 418395af0f..0816637acb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** minor (Python cells in the interface; earlier: Python cells stored and kept in files; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** minor (the Python editor window; earlier: Python cells in the interface; earlier: Python cells stored and kept in files; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **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-10-26 | The Python editor: a resizable window for a Python cell's code, with line numbers and Python colours, opened from a button beside the formula bar's PY badge, from Edit in Python Editor in a Python cell's menu, or with `ui.pythonEditor`; Ctrl+Enter or Apply puts the code in the cell through `python.set`, and closing it, opening it on another cell, closing or leaving its workbook, or quitting first asks about unsaved code. Typing or pasting past what a cell holds is refused as it happens. The code still doesn't run | | 2026-10-26 | Python cells in the interface: Python mode in the formula bar (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell, Formulas › Insert Python) with Enter for a new line, Tab to indent and Ctrl+Enter to commit through `python.set`; PY tags on Python cells and a card with the cached result or why the cell shows `#BLOCKED!`; Ctrl+Alt+Shift+M and the cell menu switch between Python Object and Excel Value (`python.output`). The code still doesn't run | | 2026-10-25 | 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-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | @@ -244,6 +245,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | Progress log: the Python editor window | | 2026-10-26 | minor | Progress log: Python cells in the interface | | 2026-10-25 | minor | Progress log: Python cells as data | | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | diff --git a/docs/architecture.md b/docs/architecture.md index 9f81c6ecd0..dd27c48a22 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Python cells in the interface; earlier: Python cells as data; earlier: engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (the Python editor window; earlier: Python cells in the interface; earlier: Python cells as data; earlier: engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365) How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md). @@ -150,7 +150,15 @@ the formula bar only, through `code_editor::code_field`: the code editor's in-or of a Python edit runs `python.set`, except that committing empty code clears the cell through `cell.set`. `python_card.rs` holds the PY tag (painted in the grid's logical coordinates, so the grid's mirroring places it on right-to-left sheets), the card -(`ui.pythonCard`) and the cell menu's return-type items. +(`ui.pythonCard`) and the cell menu's return-type items. `python_window.rs` is the Python editor +window: its own copy of one cell's code (by workbook uid, sheet and address) in `code_field` +with the line-number gutter (painted for the rows in view) and an `accept` check that refuses an +edit past `PyCode::fits` as it is typed or pasted. Apply runs `python.set` in the window's +workbook. The questions about unsaved code are a state of the window (`Pending`), so a +programmatic `ui.pythonEditor` never opens one; it refuses, or discards with `force`. With native +windows, where each window draws the app with its own workbook active, the editor is drawn only in +its workbook's window (`PyWindow::window`), and a question or an opening brings that window +forward. ## Agent control @@ -177,6 +185,7 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | The Python editor window (`python_window.rs`): the code field's gutter and `accept` check, and the window's questions as state | | 2026-10-26 | minor | Python cells in the interface: Python mode on the shared editor, `code_field` and its stop keys, `python_card.rs`, `python.output` | | 2026-10-25 | minor | Python cells as data: `model::PyCode`, the file forms, calc laying out kept results, and the limits on open | | 2026-10-24 | minor | Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 58d103994f..9885607204 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -57,7 +57,7 @@ connection; nothing sent after it runs. Blank lines are skipped. | `document.inspect` | | Workbook summary: sheets, used ranges, tables, charts, names, selection, undo labels | | `sheet.read` | `{range?, sheet?, formulas?, formatted?}` | Values (or formulas / displayed text) of a range | | `cell.get` | `{cell?}` | One cell: value, formula, displayed text, style, merge, comment, link | -| `ui.inspect` | | UI state: ribbon tab, editor (`python` is true in Python mode), open dialog, Backstage page, message, grid rect, scroll, perf, `windows` (as `window.list`) and `activeWindow` | +| `ui.inspect` | | UI state: ribbon tab, editor (`python` is true in Python mode), the Python editor window (`pythonEditor`: open, cell, text, dirty, pending question), open dialog, Backstage page, message, grid rect, scroll, perf, `windows` (as `window.list`) and `activeWindow` | | `ui.click` / `ui.drag` / `ui.move` | `{x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?}` | Real pointer input in window points; refused with an error if it would leave more than 10,000 synthetic events or 8 MiB of synthetic input queued (pointer, key, text and move events share this budget) | | `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing); same shared queue limits | | `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing); same shared queue limits, and one `ui.text` string may be at most 1 MiB | @@ -65,12 +65,13 @@ connection; nothing sent after it runs. Blank lines are skipped. | `ui.ribbon` | `{tab}` | Switch ribbon tab | | `ui.dialog` / `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{name}` / `{field, value}` | Open, fill in and confirm dialogs; `confirm` runs the dialog's command with the same parameters as its OK button | | `ui.pythonMode` (via `engine.execute`) | `{on?}` | Python mode on the active cell, in the formula bar (an edit in progress keeps its text); `on: false` turns it off; without `on` it switches. Opens no dialog | +| `ui.pythonEditor` (via `engine.execute`) | `{on?, cell?, force?}` | Open the Python editor window on `cell` (`A1` or `Sheet2!B3`; default: the active cell; a cell inside a Python cell's spill opens its Python cell), or close it with `on: false`; without `on` it opens or closes. Opens no dialog and never asks: with unsaved code in the window it refuses to close it or open it on another cell, unless `force: true` discards the code. Refused on a Hidden cell of a protected sheet, whose code stays out of view. Replies with the window's state. `file.close`, and `window.close` on the workbook's last window, are refused while the window has unsaved code for the workbook; `app.quit` is refused while it has unsaved code | | `ui.pythonCard` (via `engine.execute`) | `{on?}` | Open the card of the active cell's Python cell (its cached result's type and preview), or close it with `on: false`; an error when the active cell isn't a Python cell, or while a cell is being edited | | `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`) | | `ui.resize` / `ui.focus` | `{width, height}` | Window control | -| `app.open` / `app.save` / `app.quit` | `{path, password?}` / `{path?}` | Files and lifetime. `password` opens a password-protected workbook; without it, such a workbook returns an error (never a dialog) | +| `app.open` / `app.save` / `app.quit` | `{path, password?}` / `{path?}` | Files and lifetime. `password` opens a password-protected workbook; without it, such a workbook returns an error (never a dialog). `app.quit` doesn't ask about unsaved workbooks; it is refused while the Python editor window has unsaved code | ### Screenshot budgets diff --git a/docs/function-parity.md b/docs/function-parity.md index 689965343d..fd47571031 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-26 · **Change:** minor (Python cells are edited in the formula bar and switch return type; earlier: the Python errors `#PYTHON!`, `#BUSY!`, `#TIMEOUT!` and `#BLOCKED!`, and a Python in cells row; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-26 · **Change:** minor (Python cells are also edited in the Python editor window; earlier: Python cells are edited in the formula bar and switch return type; earlier: the Python errors `#PYTHON!`, `#BUSY!`, `#TIMEOUT!` and `#BLOCKED!`, and a Python in cells row; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **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). @@ -94,7 +94,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`, 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; `python.output` (Ctrl+Alt+Shift+M) switches a cell between Python Object and Excel Value and lays its cached result out again. 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 | +| Python in cells | partial: Python cells are stored, edited (`python.set`, Python mode in the formula bar: Ctrl+Alt+Shift+P or `=PY` then Tab, and the Python editor window with line numbers), copied, moved and saved, and Excel's `_xlfn._xlws.PY` cells open with their cached results; `python.output` (Ctrl+Alt+Shift+M) switches a cell between Python Object and Excel Value and lays its cached result out again. 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) @@ -124,6 +124,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | Python in cells row: the Python editor window | | 2026-10-26 | minor | Python in cells row: Python mode in the formula bar, and `python.output` for the return type (Python Object or Excel Value) | | 2026-10-25 | 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-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e04731a438..0149efd55b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Python cells: Python mode in the formula bar, PY tags, the card, the return-type switch and Formulas › Insert Python; earlier: AutoSum over a selected row puts its total to the right) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (the Python editor window; earlier: Python cells: Python mode in the formula bar, PY tags, the card, the return-type switch and Formulas › Insert Python; earlier: AutoSum over a selected row puts its total to the right) · **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 | 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, PY tags, the card | yes | yes (10-26): Ctrl+Alt+Shift+P (Cmd+Option+Shift+P on a Mac), `=PY` then Tab, editing a Python cell, or Formulas › Insert Python 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. Python cells carry a PY tag (at the cell's start, mirrored on a right-to-left sheet); a click on it or Ctrl+Shift+F5 opens a card with the cached result's type and preview. Ctrl+Alt+Shift+M and the cell menu switch between Python Object and Excel Value (`python.output`) | this build stores Python code and doesn't run it: new or changed code shows `#BLOCKED!`, and its card says so; no `xl()` pointing on the sheet yet | +| Python editor window | yes | yes (10-26): a resizable window for one Python cell's code, opened from the button beside the formula bar's PY badge (taking the code typed so far in Python mode), from Edit in Python Editor in a Python cell's menu, or with `ui.pythonEditor`. It shows the cell's address, its return type and the code with line numbers (the gutter widens at 10, 100 and 1,000 lines) and Python colours; Enter and Tab work as in Python mode, Ctrl+Enter (Cmd+Enter) or Apply puts the code in the cell through `python.set` and keeps the window open, and a refused commit keeps the code with the command's message under it. Esc or Close closes it; with unsaved code, closing, opening it on another cell, File › Close, closing the workbook's last window, switching workbooks and quitting (the window's close button, Quit or Cmd+Q, the Dock) first ask Apply, Discard or Cancel in the window, and `app.quit` is refused. Typing or pasting past what a cell holds (8,192 characters counted as a PY formula) is refused as it happens, with a message. The button, the menu entry and `ui.pythonEditor` don't open it on a Hidden cell of a protected sheet, and protecting the sheet closes a window on such a cell (unsaved code goes to the clipboard, with a notice). It fits a 600×400 window. With a native window per workbook it shows in its own workbook's window, which comes forward when it asks something; another workbook's window coming forward leaves it as it is | no error underline or output pane: this build runs no code; one editor at a time | | Formulas › Error Checking arrow: Circular References | yes | yes: the button opens Error Checking and its arrow opens a menu with Error Checking… and Circular References, which lists a cell on each circular reference as `Sheet!A1`, up to 100, and selects the one picked, on its own sheet (unavailable when there are none) | done; the Error Checking… item and the limit of 100 are GridCraft's own | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); New sheet (+) right after the last tab, the strip capped at 60% of the bar (#943); manual wheel/trackpad browsing preserved; next/previous skips Hidden and VeryHidden sheets and retains the current sheet and selection when no visible destination exists; rename focus bug on macOS (#145) | @@ -189,6 +190,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | The Python editor window: line numbers, Apply and Ctrl+Enter through `python.set`, the questions before unsaved code is lost (closing, another cell, closing or leaving the workbook, quitting), the limit applied while typing and pasting, and `ui.pythonEditor` | | 2026-10-26 | minor | Python cells in the interface. 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, Formulas › Insert Python with key tip MY), 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. PY tags mark Python cells; the tag or Ctrl+Shift+F5 opens a card with the cached result's type and preview, or the sentence that this build stores Python code but does not run it, placed under the cell and mirrored on right-to-left sheets. Ctrl+Alt+Shift+M and the cell menu switch between Python Object and Excel Value | | 2026-10-25 | minor | AutoSum (Alt+=, Home › AutoSum) over a selected row puts its total to the right of the row, or in its last cell when that is empty, as in Excel; it wrote a one-cell total under each cell. A selection's empty last column takes each row's total and its empty last row each column's, with the block's total in the corner when both are empty | | 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified |