From a3316d2383377e0fa329e888012e6d8180259223 Mon Sep 17 00:00:00 2001 From: Katt <238968166+KattKnight@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:43:43 +0530 Subject: [PATCH 1/3] Links: wake for the first look at a link placed while idle The link watch wakes the app every two seconds only while the last look found links (068a934, #967). A file placed less than two seconds after a look that found none asked for no more frames, so while the window sat idle nothing stamped it; a change made meanwhile was taken for the file as first seen and never acted on. The watch now remembers the active document's uid and revision at each look and, when they differ on a later frame, asks for one wake when the next look is due. There is still no per-frame walk of the links, and no two-second wakes for a document without links. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 13 ++++++- crates/ui-egui/src/tests_place.rs | 65 +++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) 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"); +} From d5bc4ceeee749a9ceb1973cf0a02b343a8fc9eec Mon Sep 17 00:00:00 2001 From: Katt <238968166+KattKnight@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:48:10 +0530 Subject: [PATCH 2/3] contributors: KattKnight's display name Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 7477eff3..45cf1695 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -58,3 +58,7 @@ sync_github_name = false real_name = "Fabio William Conceição" display_name = "Messhias" sync_github_name = false + +[people.KattKnight] +display_name = "KattKnight (ayla.dsc)" +sync_github_name = false From 340200a6ecb8acdc75f3d331683565c6e9d4083e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 04:14:12 +0700 Subject: [PATCH 3/3] ROADMAP: links followed from the first look (#1031) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index f79f0acc..c5c63136 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| -| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). | +| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). | | 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | | 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day | | 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |