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.
This commit is contained in:
dotCooCoo committed 2026-11-11 21:51:37 -08:00
1 parent 2e1d17e1fa
commit bdabdc8155
7 files changed
+101 -21

No files matched your search

+3
View File
@@ -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()
}
+26
View File
@@ -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]) {
+11 -6
View File
@@ -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| {
+7 -3
View File
@@ -299,9 +299,13 @@ pub fn text_attrs(app: &mut DesignApp) -> Option<Value> {
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<usize> {
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.
+14 -3
View File
@@ -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!(
-8
View File
@@ -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;
+40 -1
View File
@@ -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<super::Job>, std::sync::mpsc::Sender<super::Found>) {
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);
}