From 71669dae6d49eae58f3ad7217b25882195007a67 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 14:07:58 -0700 Subject: [PATCH 1/2] Error Checking: its arrow opens a Circular References submenu listing a cell on each loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formulas > Error Checking is now a split button. The button opens the Error Checking dialog as before, and its arrow opens a menu with Error Checking… and Circular References. Circular References lists a cell on each circular reference as Sheet!A1, up to 100, and picking one selects it on its own sheet and scrolls the grid to it. With no circular reference the item is unavailable. The calculation kept only the loops its latest run found, so typing in any cell outside a loop emptied the list, and the status bar's Circular References with it, while the loop was still there. A partial recalculation now keeps the loops among the formulas it does not evaluate. In automatic calculation a loop leaves the list once an edit breaks it. In manual calculation only the edited formulas run, so a listed cell stays until it is edited or the workbook is recalculated. A full recalculation or iterative calculation builds the list again. --- crates/calc/src/recalc.rs | 14 +- crates/calc/src/tests.rs | 26 ++++ crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/el.rs | 1 + crates/ui-egui/src/i18n/es.rs | 1 + crates/ui-egui/src/i18n/fr.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ko.rs | 1 + crates/ui-egui/src/i18n/nl.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt.rs | 1 + crates/ui-egui/src/i18n/ro.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/sr.rs | 1 + crates/ui-egui/src/i18n/tr.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/i18n/vi.rs | 1 + crates/ui-egui/src/i18n/zh.rs | 1 + crates/ui-egui/src/ribbon.rs | 42 +++++- crates/ui-egui/src/widgets.rs | 28 ++++ .../ui-egui/tests/it/error_checking_menu.rs | 129 ++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/ui-parity.md | 1 + 25 files changed, 254 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/tests/it/error_checking_menu.rs diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 66374bed2..4650c2073 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -1103,7 +1103,12 @@ impl Calc { } let tw = prof_now(); self.last_recalc_cells += results.len(); - self.circular = cycles; + // The loops this round finds replace the ones listed for the formulas it evaluated. + if !self.circular.is_empty() { + let evaluated: HashSet = dirty.iter().copied().collect(); + self.circular.retain(|k| !evaluated.contains(k)); + } + self.circular.extend(cycles); // Write back. let mut spill_changes: Vec = Vec::new(); let mut freed: Vec<(usize, RangeRef)> = Vec::new(); @@ -1216,6 +1221,13 @@ impl Calc { } dirty = self.dirty_closure(wb, &spill_changes); } + // Loops among formulas the run did not evaluate stay listed while they are still formulas. + // Iterative calculation reports none. + if wb.calc.iterative { + self.circular.clear(); + } else { + self.circular.retain(|k| self.graph.nodes.contains_key(k)); + } } } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index ef21c49e3..e554f0196 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -384,6 +384,32 @@ fn circular_references_without_iterative_calculation() { assert_eq!(t.get("A1"), Value::Error(CellError::Circ)); } +/// The list of circular references keeps the loops that a recalculation of other cells does not +/// reach, and drops a loop once a constant is typed over one of its cells. +#[test] +fn circular_references_stay_listed_until_the_loop_is_removed() { + let mut t = T::new(); + t.set("A1", "=A1+1"); + t.set("B1", "=B2"); + t.set("B2", "=B1"); + assert!(t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular); + t.set("D4", "7"); + t.set("D5", "=D4*2"); + assert!(t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular); + assert!(t.calc.circular.iter().any(|k| *k == (0, c("B1")) || *k == (0, c("B2"))), "{:?}", t.calc.circular); + t.set("A1", "5"); + assert!(!t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular); + assert!(t.calc.circular.iter().any(|k| *k == (0, c("B1")) || *k == (0, c("B2"))), "{:?}", t.calc.circular); + t.set("B2", "3"); + assert!(t.calc.circular.is_empty(), "{:?}", t.calc.circular); + // In manual calculation only the edited formulas are evaluated; a constant typed over the listed cell removes it. + t.wb.calc.mode = gridcraft_model::CalcMode::Manual; + t.set("C1", "=C1+1"); + assert_eq!(t.calc.circular, vec![(0, c("C1"))]); + t.set("C1", "1"); + assert!(t.calc.circular.is_empty(), "{:?}", t.calc.circular); +} + #[test] fn date_text_coerces_in_the_workbook_date_system() { for (sys, serial) in [(gridcraft_core::DateSystem::D1900, 1462.0), (gridcraft_core::DateSystem::D1904, 0.0)] { diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index fc9181feb..2145164f9 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -142,6 +142,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Odebrat šipky", "Show Formulas" => "Zobrazit vzorce", "Error Checking" => "Kontrola chyb", + "Circular References" => "Cyklické odkazy", "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 61c2292f4..c2da15e37 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -146,6 +146,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Pfeile entfernen", "Show Formulas" => "Formeln anzeigen", "Error Checking" => "Fehlerüberprüfung", + "Circular References" => "Zirkelbezüge", "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 e54416a6f..92b0efa6f 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -141,6 +141,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Κατάργηση βελών", "Show Formulas" => "Εμφάνιση τύπων", "Error Checking" => "Έλεγχος σφαλμάτων", + "Circular References" => "Κυκλικές αναφορές", "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 36898c098..4c8ad097f 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -145,6 +145,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Quitar flechas", "Show Formulas" => "Mostrar fórmulas", "Error Checking" => "Comprobación de errores", + "Circular References" => "Referencias circulares", "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 317647c2f..a11c8b42d 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -154,6 +154,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Supprimer les flèches", "Show Formulas" => "Afficher les formules", "Error Checking" => "Vérification des erreurs", + "Circular References" => "Références circulaires", "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 65f356669..f969e9d55 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -149,6 +149,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Rimuovi frecce", "Show Formulas" => "Mostra formule", "Error Checking" => "Controllo errori", + "Circular References" => "Riferimenti circolari", "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 2df4cfc11..fcc0dffbc 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -154,6 +154,7 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Remove Arrows", "矢印の解除"), ("Show Formulas", "数式の表示"), ("Error Checking", "エラーチェック"), + ("Circular References", "循環参照"), ("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 d3c440cf3..143f3eb78 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -151,6 +151,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "화살표 제거", "Show Formulas" => "수식 표시", "Error Checking" => "오류 검사", + "Circular References" => "순환 참조", "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 c1f952357..66a2b5fe6 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -150,6 +150,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Pijlen verwijderen", "Show Formulas" => "Formules weergeven", "Error Checking" => "Foutopsporing", + "Circular References" => "Kringverwijzingen", "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 e3185d55a..f552de7fd 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -145,6 +145,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Usuń strzałki", "Show Formulas" => "Pokaż formuły", "Error Checking" => "Sprawdzanie błędów", + "Circular References" => "Odwołania cykliczne", "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 1b56ff504..3307f30bf 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -148,6 +148,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Remover Setas", "Show Formulas" => "Exibir Fórmulas", "Error Checking" => "Verificação de Erros", + "Circular References" => "Referências Circulares", "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 9e0d92516..6fa0390ec 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -152,6 +152,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Eliminare săgeți", "Show Formulas" => "Afișare formule", "Error Checking" => "Verificare erori", + "Circular References" => "Referințe circulare", "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 ce675701e..b74f34c19 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -151,6 +151,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Убрать стрелки", "Show Formulas" => "Показать формулы", "Error Checking" => "Проверка ошибок", + "Circular References" => "Циклические ссылки", "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 c71ce53a8..221cb0305 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -146,6 +146,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Ukloni strelice", "Show Formulas" => "Prikaži formule", "Error Checking" => "Provera grešaka", + "Circular References" => "Kružne reference", "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 ce2bc8e1e..46abcf1a3 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -144,6 +144,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Okları Kaldır", "Show Formulas" => "Formülleri Göster", "Error Checking" => "Hata Denetimi", + "Circular References" => "Döngüsel Başvurular", "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 97659cc12..12933c630 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -147,6 +147,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Прибрати стрілки", "Show Formulas" => "Показати формули", "Error Checking" => "Перевірка помилок", + "Circular References" => "Циклічні посилання", "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 94c32b3a5..ad4fa9013 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -153,6 +153,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "Bỏ mũi tên", "Show Formulas" => "Hiện công thức", "Error Checking" => "Kiểm tra lỗi", + "Circular References" => "Tham chiếu vòng", "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 c9d3ecb7d..1dfda444e 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -151,6 +151,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Remove Arrows" => "删除箭头", "Show Formulas" => "显示公式", "Error Checking" => "错误检查", + "Circular References" => "循环引用", "Evaluate Formula" => "公式求值", "Watch Window" => "监视窗口", "Calculation\nOptions" => "计算\n选项", diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index ab9cc311d..85f5b81cf 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -8,7 +8,7 @@ use crate::SheetApp; use crate::icons::{self, Icon}; use crate::keytips::keytip_table; use crate::theme::{self, Tokens}; -use crate::widgets::{big_button, color_palette, icon_button, small_button, split_button, toggle_button}; +use crate::widgets::{big_button, color_palette, icon_button, small_button, small_split_button, split_button, toggle_button}; pub const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"]; @@ -382,6 +382,23 @@ fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) { app.run_or_alert(id, params); } +/// How many cells Formulas › Error Checking › Circular References lists. +const CIRCULAR_MENU_LIMIT: usize = 100; + +/// The cell listed for each circular reference, for the Circular References submenu: each once, in the order +/// the calculation found them, as (sheet index, cell, "Sheet!A1"). +fn circular_references(app: &SheetApp) -> Vec<(usize, String, String)> { + let Some(d) = app.session.active() else { return Vec::new() }; + let mut seen = std::collections::HashSet::new(); + d.calc + .circular + .iter() + .filter(|k| seen.insert(**k)) + .filter_map(|(si, c)| d.wb.sheet(*si).map(|sh| (*si, c.a1(), format!("{}!{}", sh.name, c.a1())))) + .take(CIRCULAR_MENU_LIMIT) + .collect() +} + /// Keytips are painted, not focusable widgets: the grid retains keyboard focus. fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) { let Some(rest) = app.keytips.prefix().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return }; @@ -2009,11 +2026,28 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { if __k.clicked() { act(app, "formulas.showFormulas", json!({})); } - let __k = small_button(ui, Icon::Validation, "Error Checking", "Error Checking", false); - control_keytip(app, ui, &__k, "dialog:errorChecking"); - if __k.clicked() { + let (ec, ec_arrow) = small_split_button(ui, Icon::Validation, "Error Checking", "Error Checking"); + control_keytip(app, ui, &ec, "dialog:errorChecking"); + if ec.clicked() { app.open_dialog("errorChecking", json!({})); } + egui::Popup::menu(&ec_arrow).show(|ui| { + let lang = app.ui.language; + if ui.button(format!("{}…", lang.tr("Error Checking"))).clicked() { + app.open_dialog("errorChecking", json!({})); + } + let circular = circular_references(app); + ui.add_enabled_ui(!circular.is_empty(), |ui| { + ui.menu_button(lang.tr("Circular References"), |ui| { + for (sheet, cell, label) in circular { + if ui.button(label).clicked() { + act(app, "selection.set", json!({"range": cell, "sheet": sheet})); + app.grid.ensure_visible = true; + } + } + }); + }); + }); let __k = small_button(ui, Icon::Calc, "Evaluate Formula", "Evaluate Formula", false); control_keytip(app, ui, &__k, "dialog:evaluateFormula"); if __k.clicked() { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e5322c3ec..6a7b3bfb5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -160,6 +160,34 @@ pub fn split_button(ui: &mut Ui, icon: Icon, accent: Option, tip: &str) (m.on_hover_text(tip_text(ui, tip)).clicked(), a) } +/// A small ribbon split button with a label (Error Checking): the icon and label run the default +/// action, the arrow at the right opens a menu. Returns (main response, arrow response). +pub fn small_split_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str) -> (Response, Response) { + let t = Tokens::get(ui.ctx()); + let font = theme::ui_font(12.5); + let label = tr(ui, label); + let tw = ui.painter().layout_no_wrap(label.to_string(), font.clone(), t.text).size().x + 6.0; + let (rect, _) = ui.allocate_exact_size(vec2(24.0 + tw + 14.0, 23.0), Sense::hover()); + let main = Rect::from_min_size(rect.min, vec2(24.0 + tw, rect.height())); + let arrow = Rect::from_min_max(pos2(main.right(), rect.top()), rect.max); + let m = ui.interact(main, ui.id().with((tip, "main")), Sense::click()); + let a = ui.interact(arrow, ui.id().with((tip, "arrow")), Sense::click()); + if m.hovered() || a.hovered() { + ui.painter().rect_filled(rect, 4.0, t.hover); + for (part, r) in [(&m, main), (&a, arrow)] { + if part.is_pointer_button_down_on() { + ui.painter().rect_filled(r, 4.0, t.pressed); + } + } + ui.painter().line_segment([pos2(main.right(), rect.top() + 3.0), pos2(main.right(), rect.bottom() - 3.0)], Stroke::new(1.0, t.separator)); + } + icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 12.0, rect.center().y), vec2(16.0, 16.0)), icon, t.text); + ui.painter().text(pos2(rect.left() + 24.0, rect.center().y), Align2::LEFT_CENTER, label, font, t.text); + icons::paint(ui.painter(), Rect::from_center_size(pos2(arrow.center().x, rect.center().y + 1.0), vec2(10.0, 10.0)), Icon::Chevron, t.text_dim); + let tip = tip_text(ui, tip); + (m.on_hover_text(tip.clone()), a.on_hover_text(tip)) +} + /// A one-line text field for a value stored in the workbook, which callers reload every frame. The text being typed is /// kept in egui memory until the field loses focus, then returned to commit (`None` when Escape cancels the edit). pub fn committed_text(ui: &mut Ui, id: egui::Id, current: &str, width: Option) -> Option { diff --git a/crates/ui-egui/tests/it/error_checking_menu.rs b/crates/ui-egui/tests/it/error_checking_menu.rs new file mode 100644 index 000000000..0639020da --- /dev/null +++ b/crates/ui-egui/tests/it/error_checking_menu.rs @@ -0,0 +1,129 @@ +//! Formulas › Error Checking is a split button, as in Excel: the button opens the Error Checking +//! dialog, and its arrow opens a menu whose Circular References submenu lists a cell on each +//! circular reference and selects the one picked, on its own sheet. + +use egui::{Event, Shape, pos2, vec2}; +use egui_kittest::kittest::{NodeT as _, Queryable as _}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// The submenu item, with the arrow egui puts after a submenu's label. +const CIRCULAR: &str = "Circular References ⏵"; + +fn harness(loops: bool) -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.insertSheet", json!({"name": "Data"})).unwrap(); + if loops { + s.execute("cell.set", json!({"cell": "B3", "input": "=B4"})).unwrap(); + s.execute("cell.set", json!({"cell": "B4", "input": "=B3"})).unwrap(); + } + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + if loops { + s.execute("cell.set", json!({"cell": "C2", "input": "=C2+1"})).unwrap(); + } + s.execute("selection.set", json!({"range": "H20"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h.state_mut().ui.ribbon_tab = "Formulas".into(); + h.run_steps(4); + h +} + +/// The painted "Error Checking" ribbon label: its left edge, right edge and vertical center. +fn label(h: &Harness) -> (f32, f32, f32) { + h.output() + .shapes + .iter() + .find_map(|s| match &s.shape { + Shape::Text(t) if t.galley.job.text == "Error Checking" => { + Some((t.pos.x, t.pos.x + t.galley.size().x, t.pos.y + t.galley.size().y / 2.0)) + } + _ => None, + }) + .expect("the Formulas tab shows Error Checking") +} + +fn click(h: &mut Harness, at: egui::Pos2) { + h.input_mut().events.push(Event::PointerMoved(at)); + h.run_steps(2); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + } + h.run_steps(2); +} + +fn open_menu(h: &mut Harness) { + let (_, right, y) = label(h); + // The arrow sits right of the label, past the 6 px gap after the text. + click(h, pos2(right + 13.0, y)); +} + +fn hover(h: &mut Harness, label: &str) { + let at = h.get_by_label(label).rect().center(); + h.input_mut().events.push(Event::PointerMoved(at)); + h.run_steps(6); +} + +#[test] +fn error_checking_button_opens_the_dialog_and_its_arrow_opens_the_menu() { + let mut h = harness(false); + let (left, _, y) = label(&h); + click(&mut h, pos2(left + 4.0, y)); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("errorChecking"), "the button opens Error Checking"); + h.state_mut().dialog = None; + h.run_steps(2); + open_menu(&mut h); + assert!(h.state().dialog.is_none(), "the arrow opens a menu, not the dialog"); + h.get_by_label("Error Checking…").click(); + h.run_steps(4); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("errorChecking"), "the menu's first item opens Error Checking"); +} + +#[test] +fn circular_references_is_unavailable_without_a_circular_reference() { + let mut h = harness(false); + open_menu(&mut h); + assert!(h.get_by_label(CIRCULAR).accesskit_node().is_disabled()); +} + +#[test] +fn circular_references_lists_the_loops_and_selects_the_cell_picked() { + let mut h = harness(true); + open_menu(&mut h); + assert!(!h.get_by_label(CIRCULAR).accesskit_node().is_disabled()); + hover(&mut h, CIRCULAR); + assert!(h.query_by_label("Sheet1!C2").is_some(), "the loop on the active sheet is listed"); + let other = ["Data!B3", "Data!B4"].into_iter().find(|l| h.query_by_label(l).is_some()).expect("the loop on Data is listed"); + h.get_by_label(other).click(); + h.run_steps(4); + let app = h.state(); + let d = app.session.active().unwrap(); + assert_eq!(d.wb.active_sheet, 1, "picking a cell on Data goes to Data"); + assert_eq!(format!("Data!{}", d.selection.active.a1()), other); +} + +#[test] +fn circular_references_scrolls_to_the_cell_picked() { + let mut h = harness(false); + h.state_mut().run("cell.set", json!({"cell": "C500", "input": "=C500+1"})).unwrap(); + h.state_mut().run("selection.set", json!({"range": "H20"})).unwrap(); + h.run_steps(4); + assert_eq!(h.state().view().scroll.y, 0.0, "setup: the view is at the top of the sheet"); + open_menu(&mut h); + hover(&mut h, CIRCULAR); + h.get_by_label("Sheet1!C500").click(); + h.run_steps(4); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "C500"); + assert!(h.state().view().scroll.y > 0.0, "the grid scrolls to the cell picked"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 3a9769c1c..1422e683e 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -24,6 +24,7 @@ mod dialog_confirm; mod dialog_focus; mod dialog_move; mod drag_cursor; +mod error_checking_menu; mod file_menu; mod fill_handle_double_click; mod fill_handle_right_drag; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5d5f1efee..b3bb9e0e5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -24,6 +24,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Formula bar with Name Box, expand, function hints | yes | yes | done | | 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 | +| 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) | | Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes | customizable statistics partial | From a2e4b2b1da6d70844f0e2a0f53397b79beedc2a9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 15:48:55 -0700 Subject: [PATCH 2/2] Error Checking: the Circular References submenu scrolls The submenu lists up to 100 cells. When the list was taller than the window, the cells below its bottom edge could not be reached. The list now sits in a scroll area 400 points high. --- crates/ui-egui/src/ribbon.rs | 12 ++++---- .../ui-egui/tests/it/error_checking_menu.rs | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 85f5b81cf..04c0e814e 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2039,12 +2039,14 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { let circular = circular_references(app); ui.add_enabled_ui(!circular.is_empty(), |ui| { ui.menu_button(lang.tr("Circular References"), |ui| { - for (sheet, cell, label) in circular { - if ui.button(label).clicked() { - act(app, "selection.set", json!({"range": cell, "sheet": sheet})); - app.grid.ensure_visible = true; + egui::ScrollArea::vertical().id_salt("circular_references").max_height(400.0).show(ui, |ui| { + for (sheet, cell, label) in circular { + if ui.button(label).clicked() { + act(app, "selection.set", json!({"range": cell, "sheet": sheet})); + app.grid.ensure_visible = true; + } } - } + }); }); }); }); diff --git a/crates/ui-egui/tests/it/error_checking_menu.rs b/crates/ui-egui/tests/it/error_checking_menu.rs index 0639020da..10467e163 100644 --- a/crates/ui-egui/tests/it/error_checking_menu.rs +++ b/crates/ui-egui/tests/it/error_checking_menu.rs @@ -127,3 +127,31 @@ fn circular_references_scrolls_to_the_cell_picked() { assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "C500"); assert!(h.state().view().scroll.y > 0.0, "the grid scrolls to the cell picked"); } + +#[test] +fn circular_references_scrolls_a_list_taller_than_the_window() { + let mut h = harness(false); + for row in 1..=100 { + h.state_mut().run("cell.set", json!({"cell": format!("A{row}"), "input": format!("=A{row}+1")})).unwrap(); + } + h.state_mut().run("selection.set", json!({"range": "H20"})).unwrap(); + h.run_steps(4); + open_menu(&mut h); + hover(&mut h, CIRCULAR); + let window = h.ctx.content_rect(); + assert!(h.get_by_label("Sheet1!A100").rect().bottom() > window.bottom(), "setup: the last cell starts below the window"); + hover(&mut h, "Sheet1!A1"); + for _ in 0..20 { + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, -10.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(4); + } + let last = h.get_by_label("Sheet1!A100").rect(); + assert!(last.bottom() <= window.bottom(), "the wheel brings the last cell into the window: {last:?} in {window:?}"); + click(&mut h, last.center()); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A100"); +}