diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index eabc29314..a2547271e 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -1132,7 +1132,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(); @@ -1245,6 +1250,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 1720bcb57..5b9e90085 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..04c0e814e 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,30 @@ 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| { + 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; + } + } + }); + }); + }); + }); 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..10467e163 --- /dev/null +++ b/crates/ui-egui/tests/it/error_checking_menu.rs @@ -0,0 +1,157 @@ +//! 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"); +} + +#[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"); +} 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 e6b7b8ea5..f6c0f7ed9 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 |