diff --git a/crates/engine/src/link_watch.rs b/crates/engine/src/link_watch.rs index 44b1bd86a..6cdf5c359 100644 --- a/crates/engine/src/link_watch.rs +++ b/crates/engine/src/link_watch.rs @@ -33,7 +33,7 @@ pub type LinkScan = Arc<(Mutex, Option /// Held by a look's worker: marks the look as ended and wakes [`Session::wait_link_scan`] when /// dropped, whether the worker returned or unwound. -struct Ended(LinkScan); +pub(crate) struct Ended(pub(crate) LinkScan); impl Drop for Ended { fn drop(&mut self) { @@ -44,6 +44,14 @@ impl Drop for Ended { } } +/// Whether a look's worker ended without storing stamps, with `read_ended` reading the flag. The +/// worker stores its stamps and marks the look as ended under the slot's lock, and this holds that +/// lock across both reads, so a look whose worker is storing its stamps never counts as ended without them. +pub(crate) fn ended_without_stamps(scan: &LinkScan, read_ended: impl FnOnce(&AtomicBool) -> bool) -> bool { + let stamps = scan.0.lock().unwrap_or_else(PoisonError::into_inner); + stamps.is_none() && read_ended(&scan.2) +} + /// The stamp of the linked file stored as `path` in the document saved at `doc_path`; `None` when /// there is no file, its path isn't followed ([`crate::cmd::fileio::document_path`]) or /// automation may not read it. @@ -108,8 +116,10 @@ impl Session { } /// Apply a finished look (`None` while there is none, or it is still running): see - /// [`Session::apply_link_stamps`]. + /// [`Session::apply_link_stamps`]. A look whose worker ended without stamps is cleared, also + /// with `None`, so the next one can start. pub fn poll_link_scan(&mut self) -> Option> { + self.link_scan.take_if(|scan| ended_without_stamps(scan, |ended| ended.load(Ordering::SeqCst))); let (uid, stamps) = self.link_scan.as_ref()?.0.lock().unwrap_or_else(PoisonError::into_inner).take()?; self.link_scan = None; Some(self.apply_link_stamps(uid, stamps, false)) @@ -117,7 +127,8 @@ impl Session { /// Block until the look in flight has ended (at once when none is running), so that the next /// [`Session::poll_link_scan`] applies its stamps. A look whose worker stopped without stamps - /// ends too, and the poll then returns `None`. For tests that drive the look step by step. + /// ends too, and the poll then clears it and returns `None`. For tests that drive the look step + /// by step. pub fn wait_link_scan(&self) { let Some(scan) = &self.link_scan else { return }; let (done, finished, ended) = &**scan; diff --git a/crates/engine/src/tests/linkwatch.rs b/crates/engine/src/tests/linkwatch.rs index f1192e02d..37aac5df5 100644 --- a/crates/engine/src/tests/linkwatch.rs +++ b/crates/engine/src/tests/linkwatch.rs @@ -1,6 +1,8 @@ //! Linked files changing while their document is open (`link_watch`): `links.updateChanged`, //! and the background look following Preferences → File Handling → Update Links. +use std::sync::atomic::Ordering; + use serde_json::{Value, json}; use crate::tests_links::{BLUE, Folder, RED, centre_colour, place, png, session}; @@ -84,6 +86,67 @@ fn a_file_still_being_written_is_tried_again() { assert_eq!(colour(&s), BLUE); } +#[test] +fn a_look_whose_worker_ended_without_stamps_does_not_stop_later_looks() { + let dir = Folder::new("watch-unwound"); + let path = dir.file("art.png"); + std::fs::write(&path, png(40, 40, RED)).unwrap(); + let mut s = session(); + s.prefs.update_links = "automatically".into(); + let id = place(&mut s, &path); + look(&mut s); + let scan = link_watch::LinkScan::default(); + s.link_scan = Some(scan.clone()); + // Still running: the look stays in flight. + assert!(s.poll_link_scan().is_none()); + assert!(s.link_scan.is_some(), "a look still running stays"); + let worker = std::thread::spawn(move || { + let _ended = link_watch::Ended(scan); + panic!("the look's worker unwinds"); + }); + assert!(worker.join().is_err()); + assert!(s.poll_link_scan().is_none(), "there are no stamps to apply"); + assert!(s.link_scan.is_none(), "the look ended"); + save(&path, &png(40, 40, BLUE)); + assert_eq!(look(&mut s)["updated"], json!([id.0])); + assert_eq!(colour(&s), BLUE); +} + +#[test] +fn a_look_that_stores_its_stamps_between_the_polls_reads_keeps_them() { + let dir = Folder::new("watch-stored"); + let path = dir.file("art.png"); + std::fs::write(&path, png(40, 40, RED)).unwrap(); + let mut s = session(); + s.prefs.update_links = "automatically".into(); + let id = place(&mut s, &path); + look(&mut s); + let uid = s.doc().unwrap().uid; + let stamps: Vec<_> = link_watch::linked_files(&s.doc().unwrap().doc).into_iter().map(|(p, ids)| (p, ids, Some((1, 1)))).collect(); + let scan = link_watch::LinkScan::default(); + s.link_scan = Some(scan.clone()); + // The worker takes its turn after the slot is read and before the flag is, if the slot is free. + let ended = link_watch::ended_without_stamps(&scan, |flag| { + if let Ok(mut slot) = scan.0.try_lock() { + *slot = Some((uid, stamps.clone())); + flag.store(true, Ordering::SeqCst); + } + flag.load(Ordering::SeqCst) + }); + assert!(!ended, "the worker waits for the poll, so the look has not ended without stamps"); + // The worker's turn comes once the poll has let go of the slot. + let worker = std::thread::spawn({ + let scan = scan.clone(); + move || { + let _ended = link_watch::Ended(scan.clone()); + *scan.0.lock().unwrap() = Some((uid, stamps)); + } + }); + worker.join().unwrap(); + assert_eq!(s.poll_link_scan().expect("the stamps are applied").unwrap()["updated"], json!([id.0])); + assert!(s.link_scan.is_none()); +} + #[test] fn a_change_during_a_drag_waits_for_the_drag_to_end() { let dir = Folder::new("watch-drag"); diff --git a/crates/ui-egui/src/tests/place.rs b/crates/ui-egui/src/tests/place.rs index 9406ba2c9..432fdaec0 100644 --- a/crates/ui-egui/src/tests/place.rs +++ b/crates/ui-egui/src/tests/place.rs @@ -781,8 +781,6 @@ fn frame_at(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64) -> std::ti /// 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; - // The look last waited for: one that a frame after the wait still holds ended without stamps. - let mut waited: Option = None; loop { let wait = frame_at(app, ctx, *time); if done(app) { @@ -795,11 +793,8 @@ fn idle(app: &mut VectorcraftApp, ctx: &egui::Context, time: &mut f64, secs: f64 } // The look runs on a worker thread, in real time: the clock waits for it to finish, else a // busy machine's slow look outlasts the simulated seconds and the test fails at random. - if let Some(scan) = app.session.link_scan.clone() - && !waited.as_ref().is_some_and(|w| std::sync::Arc::ptr_eq(w, &scan)) - { + if app.session.link_scan.is_some() { app.session.wait_link_scan(); - waited = Some(scan); continue; } *time = next; @@ -842,3 +837,45 @@ fn a_file_placed_while_idle_follows_its_changes() { }; assert!(idle(&mut app, &ctx, &mut time, 10.0, &updated), "the changed file was never read again"); } + +/// A look whose worker ended without stamps is cleared: the app stops asking for a frame every +/// 100 ms, and the next look, on schedule, reads a changed file again. +#[test] +fn a_look_whose_worker_stopped_does_not_stop_later_looks() { + let mut app = app(); + // Every frame renders on this thread: a frame finished by the render worker asks for a repaint. + app.canvas.worker_started = true; + app.run("prefs.set", json!({"key": "updateLinks", "value": "automatically"})).unwrap(); + let path = temp_file("stopped-watch.png", &png(40, 30, 72.0)); + app.run("file.place", json!({"path": path})).unwrap(); + let id = app.session.active().unwrap().selection.objects[0]; + let ctx = egui::Context::default(); + let mut time = 0.0; + idle(&mut app, &ctx, &mut time, 3.0, &|_| false); + // A look that is due starts in this frame and is applied in the next one, at the same time. + time += 3.0; + frame_at(&mut app, &ctx, time); + app.session.wait_link_scan(); + frame_at(&mut app, &ctx, time); + assert!(app.session.link_scan.is_none()); + let scan = vectorcraft_engine::link_watch::LinkScan::default(); + scan.2.store(true, std::sync::atomic::Ordering::SeqCst); + app.session.link_scan = Some(scan); + // Half a second later, before the next look is due. + time += 0.5; + let wait = frame_at(&mut app, &ctx, time); + assert!(app.session.link_scan.is_none(), "the look ended"); + assert!(wait > std::time::Duration::from_millis(100), "{wait:?}: {:?}", ctx.repaint_causes()); + let stamp = std::fs::metadata(&path).unwrap().modified().unwrap(); + let changed = png(60, 30, 72.0); + std::fs::write(&path, &changed).unwrap(); + // The rewritten file is dated 2 s later, however fast it was written. + let later = std::fs::FileTimes::new().set_modified(stamp + std::time::Duration::from_secs(2)); + std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_times(later).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"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f7aa268cd..7c87e03a8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -31,6 +31,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | The canvas keeps selection outlines, anchors and handles between frames, so an idle frame with a large selection redraws them as one mesh; scores and estimates unchanged | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-06 | minor | Stroke gradients: an arrowhead whose tip sits on the end of a long straight leg is painted by the gradient to the corners of its base. The slices that reach across a head now run to the end of its reach instead of narrowing towards the next corner (#5964). Test: `stroke::tests_gradient::slices_cover_an_arrowhead_on_a_long_straight_leg`. Scores and estimates unchanged. | | 2026-11-06 | minor | Width profiles: the width between two width points follows a smooth curve instead of a straight line, so a stroke no longer kinks at each width point (#5714). Each side is a monotone cubic through its points: flat at a widest or narrowest point, never past either point of a span; stacked points still step. Straight runs are sampled between width points to the drawing tolerance. Scores and estimates unchanged. | @@ -1300,6 +1301,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On Windows, a link on a drive mapped to a network share other than the document's (`Y:\…`) is missing, with the reason that the path is on another computer, as a link stored with that share's own path is; links on this computer's drives and on the document's share are followed. - In the Links panel, Shift-click selects a range of links and Ctrl/Cmd-click adds or removes one. - Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`). + - A look at the linked files whose worker thread ends without a result, such as after a panic, no longer stops later looks: the app clears it, stops asking for a frame every 100 ms, and the next look starts on schedule (`crates/engine/src/tests/linkwatch.rs`, `crates/ui-egui/src/tests/place.rs`). - Import and paste, 2026-10-13: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499). - A `.ai` or `.ait` file copied in a file manager pastes as its art: one that holds a PDF pastes the objects of its editing data, and one saved as PostScript pastes as a copied `.eps` file does. - A copied `.ai` or `.ait` file that holds a PDF pastes the objects of its editing data as File › Open reads them: every layer's objects go into the active layer in their stacking order, with their names, hidden objects and type objects, instead of the PDF page's art with its type as outlines. When the editing data can't be read, the file pastes its page as before (`clipboard.importPdf {editingData}`). The objects of a hidden layer come in hidden, from a pasted EPS too.