From bdabdc8155cfc69fe793240cfa83e2d418c70e53 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:51:37 -0800 Subject: [PATCH] Preflight: show a pending first check as checking, and rebuild document indexes on grid changes - The status bar and the Preflight panel show a gray light and "Checking" until the active document's first check ends, instead of a green "No errors". - The cross-reference and running-header indexes are keyed on the baseline grid and the font scope as well, so a change to either builds them again, in a cache and in a cache over it. - The test helper that waits for the preflight worker fails when the worker ends without a result. --- crates/compose/src/cache.rs | 3 ++ crates/compose/src/tests.rs | 26 ++++++++++++ crates/ui-egui/src/chrome/status_bar.rs | 17 +++++--- crates/ui-egui/src/panels.rs | 10 +++-- crates/ui-egui/src/panels/properties/links.rs | 17 ++++++-- crates/ui-egui/src/preflight_worker.rs | 8 ---- crates/ui-egui/src/preflight_worker/tests.rs | 41 ++++++++++++++++++- 7 files changed, 101 insertions(+), 21 deletions(-) diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 9cf96e497..dd7180915 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -88,6 +88,9 @@ fn doc_signature(doc: &Document) -> u64 { (Arc::as_ptr(&doc.styles) as usize).hash(&mut h); format!("{:?}", doc.text_variables).hash(&mut h); sections_hash(doc).hash(&mut h); + // The compositions the indexes are built from also depend on these (see `signature`). + let g = &doc.settings.baseline_grid; + (doc.font_scope, g.relative_to == designcraft_doc::GridRelative::TopMargin, g.start.to_bits(), g.increment.to_bits()).hash(&mut h); h.finish() } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 900bced97..7f900dd5b 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -8175,6 +8175,32 @@ fn a_cache_over_another_reads_it_without_writing_to_it() { assert!(base.composed_for(&old, sid).is_none(), "the base holds nothing of the older state"); } +/// The cross-reference and running-header indexes are built from compositions that depend on the +/// baseline grid and the font scope, so a change to either builds them again, in a cache and in a +/// cache over it. +#[test] +fn the_document_indexes_are_rebuilt_when_the_grid_or_font_scope_changes() { + let para = ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }; + let (d, _, _) = doc_with(LOREM, Rect::new(0.0, 0.0, 200.0, 600.0), para); + let base = Arc::new(Cache::new()); + let (xrefs, running) = (base.xref_index(&d), base.running_index(&d)); + let mut grid = d.clone(); + grid.settings.baseline_grid.increment = 30.0; + let mut scope = d.clone(); + scope.font_scope += 1; + for (changed, what) in [(&grid, "grid"), (&scope, "font scope")] { + let reader = Cache::over(Arc::clone(&base)); + assert!(!Arc::ptr_eq(&reader.xref_index(changed), &xrefs), "{what}: the base's cross-reference index is not used"); + assert!(!Arc::ptr_eq(&reader.running_index(changed), &running), "{what}: the base's running-header index is not used"); + let own = Cache::new(); + let (x, r) = (own.xref_index(&d), own.running_index(&d)); + assert!(!Arc::ptr_eq(&own.xref_index(changed), &x), "{what}: cross-reference index rebuilt"); + assert!(!Arc::ptr_eq(&own.running_index(changed), &r), "{what}: running-header index rebuilt"); + } + let reader = Cache::over(Arc::clone(&base)); + assert!(Arc::ptr_eq(&reader.xref_index(&d), &xrefs), "the same state uses the base's index"); +} + /// Two single pages 618 pt tall, top margins 36 and 48, each with an overset 11/13 story aligned /// to a baseline grid (start 0, increment 13) and a frame filling its margin box (bottom 582). fn grid_margin_doc(relative_to: designcraft_doc::GridRelative) -> (Document, [StoryId; 2]) { diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index c602b1721..765ec66fc 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -169,19 +169,24 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) { ui.painter().circle_filled( r.center(), 4.0, - if errors == 0 { Color32::from_rgb(60, 200, 90) } else { Color32::from_rgb(235, 50, 50) }, + match errors { + None => t.text_dim, + Some(0) => Color32::from_rgb(60, 200, 90), + Some(_) => Color32::from_rgb(235, 50, 50), + }, ); - let count = if errors == 0 { - format!("{} ▾", crate::i18n::tr(&app.ui.language, "No errors")) - } else { - format!( + let count = match errors { + // The active document's first check hasn't ended. + None => format!("{} ▾", crate::i18n::tr(&app.ui.language, "Checking")), + Some(0) => format!("{} ▾", crate::i18n::tr(&app.ui.language, "No errors")), + Some(errors) => format!( "{} ▾", crate::i18n::count_label( &app.ui.language, if errors == 1 && !crate::i18n::category_first(&app.ui.language) { "error" } else { "errors" }, errors ) - ) + ), }; // As in InDesign, the count opens a menu that leads to the Preflight panel. let menu = crate::menus::menu_button(ui, egui::RichText::new(count).font(small.clone()), |ui| { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 45606f4cd..33d9bd3c4 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -299,9 +299,13 @@ pub fn text_attrs(app: &mut DesignApp) -> Option { if v.is_null() { None } else { Some(serde_json::to_value(v).ok()?) } } -/// Count the preflight errors last found for the active document (0 until its first check ends). -pub fn preflight_errors(app: &mut DesignApp, ctx: &egui::Context) -> usize { - crate::preflight_worker::issues(app, ctx).map_or(0, |issues| issues.iter().filter(|i| i.severity == "error").count()) +/// Count the preflight errors last found for the active document: `None` until its first check +/// ends, and 0 with no document open. +pub fn preflight_errors(app: &mut DesignApp, ctx: &egui::Context) -> Option { + match crate::preflight_worker::issues(app, ctx) { + Some(issues) => Some(issues.iter().filter(|i| i.severity == "error").count()), + None => app.session.active().is_none().then_some(0), + } } /// Height of one swatch-menu row. diff --git a/crates/ui-egui/src/panels/properties/links.rs b/crates/ui-egui/src/panels/properties/links.rs index 7a1a6f0d0..f161f35f4 100644 --- a/crates/ui-egui/src/panels/properties/links.rs +++ b/crates/ui-egui/src/panels/properties/links.rs @@ -300,18 +300,29 @@ pub fn info_panel(app: &mut DesignApp, ui: &mut egui::Ui) { pub fn preflight_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let issues = crate::preflight_worker::issues(app, ui.ctx()).unwrap_or_default(); + let found = crate::preflight_worker::issues(app, ui.ctx()); + // The active document's first check hasn't ended. + let checking = found.is_none() && app.session.active().is_some(); + let issues = found.unwrap_or_default(); let errors = issues.iter().filter(|i| i.severity == "error").count(); ui.horizontal(|ui| { let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), egui::Sense::hover()); ui.painter().circle_filled( r.center(), 4.5, - if errors == 0 { egui::Color32::from_rgb(60, 200, 90) } else { egui::Color32::from_rgb(235, 50, 50) }, + if checking { + t.text_dim + } else if errors == 0 { + egui::Color32::from_rgb(60, 200, 90) + } else { + egui::Color32::from_rgb(235, 50, 50) + }, ); crate::rtl::label( ui, - if issues.is_empty() { + if checking { + crate::i18n::tr(&app.ui.language, "Checking").to_string() + } else if issues.is_empty() { crate::i18n::tr(&app.ui.language, "No errors").to_string() } else { format!( diff --git a/crates/ui-egui/src/preflight_worker.rs b/crates/ui-egui/src/preflight_worker.rs index 4da6224e5..02b27832e 100644 --- a/crates/ui-egui/src/preflight_worker.rs +++ b/crates/ui-egui/src/preflight_worker.rs @@ -420,13 +420,5 @@ pub(crate) fn start_worker(app: &mut DesignApp, ctx: &egui::Context) { app.preflight.worker.channels = spawn(ctx.clone(), &app.preflight.worker.live); } -/// Waits in a test for the worker's next result and keeps it, as a frame that polls would. -#[cfg(all(test, not(target_arch = "wasm32")))] -pub(crate) fn wait_for_worker(app: &mut DesignApp) { - if let Some(found) = app.preflight.worker.wait() { - app.preflight.keep(found); - } -} - #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/preflight_worker/tests.rs b/crates/ui-egui/src/preflight_worker/tests.rs index 9cc9a394b..ba0bfad67 100644 --- a/crates/ui-egui/src/preflight_worker/tests.rs +++ b/crates/ui-egui/src/preflight_worker/tests.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use egui::vec2; +use egui_kittest::kittest::Queryable; use serde_json::json; use super::Checked; @@ -30,6 +31,14 @@ fn found(app: &crate::DesignApp) -> Option<(usize, bool)> { Some((f.issues.iter().filter(|i| i.severity == "error").count(), f.of.same(&now))) } +/// Waits for the worker's next result and keeps it, as a frame that polls would. Panics when the +/// worker has ended without one. +#[cfg(not(target_arch = "wasm32"))] +fn wait_for_worker(app: &mut crate::DesignApp) { + let Some(found) = app.preflight.worker.wait() else { panic!("the preflight worker ended before it sent a result") }; + app.preflight.keep(found); +} + /// A worker whose thread never takes a job: the test reads the queue. fn stalled_worker(app: &mut crate::DesignApp) -> (std::sync::mpsc::Receiver, std::sync::mpsc::Sender) { let (tx, jobs) = std::sync::mpsc::sync_channel(1); @@ -298,9 +307,39 @@ fn the_worker_checks_and_the_interface_thread_does_not() { assert_eq!(h.state().app.preflight.inline_checks, 0, "no check ran on the interface thread"); // The last frame handed the current state to the worker, whose last answer is for it. while found(&h.state().app) != Some((1, true)) { - super::wait_for_worker(&mut h.state_mut().app); + wait_for_worker(&mut h.state_mut().app); } let issues = super::issues(&mut h.state_mut().app, &ctx).unwrap(); assert_eq!(issues.iter().filter(|i| i.severity == "error").count(), 1); assert_eq!(h.state().app.preflight.inline_checks, 0, "the worker's answer is kept, not checked again"); } + +/// Until the active document's first check ends, the status bar and the Preflight panel show that +/// it is being checked, not a clean result. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn the_first_check_shows_as_checking() { + let (mut app, _) = overset_app(); + app.ui.dock_tab = "properties".into(); + let (_jobs, _done) = stalled_worker(&mut app); + let mut h = test_window::open(app, vec2(1200.0, 700.0)); + h.state_mut().app.run("window.panel", json!({"panel": "preflight"})).unwrap(); + h.run_steps(5); + assert!(h.state().app.preflight.found.is_empty(), "nothing found yet"); + assert_eq!(h.state().app.preflight.inline_checks, 0); + assert!(h.query_by_label("Checking ▾").is_some(), "the status bar shows the check"); + assert!(h.query_by_label("No errors ▾").is_none()); + assert!(h.query_by_label("Checking").is_some(), "the Preflight panel shows the check"); + assert!(h.query_by_label("No errors").is_none()); +} + +/// Waiting for a worker that ended before it answered fails the test instead of leaving it +/// waiting. +#[cfg(not(target_arch = "wasm32"))] +#[test] +#[should_panic(expected = "the preflight worker ended before it sent a result")] +fn waiting_for_a_worker_that_ended_fails() { + let (mut app, _) = overset_app(); + drop(stalled_worker(&mut app)); + wait_for_worker(&mut app); +}