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 | 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 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"); +}