diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0077af03..be5c79ec 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -788,6 +788,8 @@ impl VectorcraftApp { return; } let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(f64::NEG_INFINITY); + // The active document as the last look saw it: its uid and revision. + let (seen_key, doc) = (egui::Id::new("linkWatch.seen"), self.session.active().map(|d| (d.uid, d.revision))); if now - last >= 2.0 || (focused && !was_focused) { // Walking the document's links costs a pass over it: only when a look is due. // `start_link_scan` starts none for a document without links, which then doesn't @@ -795,12 +797,21 @@ impl VectorcraftApp { ctx.data_mut(|d| d.insert_temp(last_key, now)); self.session.start_link_scan(); let links = self.session.link_scan.is_some(); - ctx.data_mut(|d| d.insert_temp(egui::Id::new("linkWatch.links"), links)); + ctx.data_mut(|d| { + d.insert_temp(egui::Id::new("linkWatch.links"), links); + d.insert_temp(seen_key, doc); + }); } if self.session.link_scan.is_some() { ctx.request_repaint_after(std::time::Duration::from_millis(100)); } else if ctx.data(|d| d.get_temp::(egui::Id::new("linkWatch.links"))).unwrap_or(false) { ctx.request_repaint_after(std::time::Duration::from_millis(2000)); + } else if ctx.data(|d| d.get_temp::>(seen_key)).is_some_and(|seen| seen != doc) { + // The document changed (a file placed into one without links) or another came to the + // front since that look: wake for the next one even if the app sits idle until then, + // or a file placed and changed meanwhile is first seen after the change, which is then + // never acted on. + ctx.request_repaint_after(std::time::Duration::from_secs_f64((last + 2.0 - now).clamp(0.0, 2.0))); } } diff --git a/crates/ui-egui/src/tests_place.rs b/crates/ui-egui/src/tests_place.rs index 5577bd76..d08044e4 100644 --- a/crates/ui-egui/src/tests_place.rs +++ b/crates/ui-egui/src/tests_place.rs @@ -546,3 +546,68 @@ fn crop_image_shows_a_box_that_apply_crops_to() { app.run("select.none", json!({})).unwrap(); assert!(!crate::menus::enabled(&app, "ui.cropImage") && app.run("ui.cropImage", json!({})).is_err()); } + +/// One headless frame of the whole window at `time` → how long the app asked to wait before the +/// next one. +fn frame_at(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64) -> std::time::Duration { + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time: Some(time), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out.viewport_output.get(&egui::ViewportId::ROOT).map_or(std::time::Duration::MAX, |v| v.repaint_delay) +} + +/// The window sits idle for `secs` from `*time` (a frame now, then only the frames the app asks +/// for) or until `done` → whether it was. +fn idle(app: &mut VectorcraftApp, ctx: &egui::Context, time: &mut f64, secs: f64, done: &dyn Fn(&VectorcraftApp) -> bool) -> bool { + let end = *time + secs; + loop { + let wait = frame_at(app, ctx, *time); + if done(app) { + return true; + } + let next = *time + wait.as_secs_f64().max(1.0 / 60.0); + if next > end { + *time = end; + return false; + } + if app.session.link_scan.is_some() { + // The look runs on a worker thread, in real time. + std::thread::sleep(std::time::Duration::from_millis(5)); + } + *time = next; + } +} + +/// A file placed into a document whose last look found no links, then changed by another app +/// while the window sits idle in the background, follows Update Links: Automatically: the app +/// wakes itself to see the new link before the change, so the change isn't taken for the file as +/// first seen. +#[test] +fn a_file_placed_while_idle_follows_its_changes() { + let mut app = app(); + app.run("prefs.set", json!({"key": "updateLinks", "value": "automatically"})).unwrap(); + let ctx = egui::Context::default(); + let mut time = 0.0; + idle(&mut app, &ctx, &mut time, 3.0, &|_| false); + // A look finds the new document without links: no wakes of its own for it. + assert_eq!(frame_at(&mut app, &ctx, time), std::time::Duration::MAX, "{:?}", ctx.repaint_causes()); + // Half a second later, before the next look is due, a file is placed. The place itself is a + // frame (as any command); then nothing happens for 3 s. + time += 0.5; + let path = temp_file("idle-watch.png", &png(40, 30, 72.0)); + app.run("file.place", json!({"path": path})).unwrap(); + let id = app.session.active().unwrap().selection.objects[0]; + idle(&mut app, &ctx, &mut time, 3.0, &|_| false); + std::thread::sleep(std::time::Duration::from_millis(30)); + let changed = png(60, 30, 72.0); + std::fs::write(&path, &changed).unwrap(); + let hash = vectorcraft_doc::links::hash_bytes(&changed); + let updated = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + matches!(&st.doc.node(id).unwrap().kind, NodeKind::Image(im) if im.link.as_ref().and_then(|l| l.hash.as_deref()) == Some(hash.as_str())) + }; + assert!(idle(&mut app, &ctx, &mut time, 10.0, &updated), "the changed file was never read again"); +}