Merge pull request #967 from storytold/feat/link-watch

Links: Update Links applies while a document is open (from #956)
This commit is contained in:
BFlat 2026-10-10 20:20:24 +07:00 committed by GitHub
commit 4eaf5e7b50
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 357 additions and 108 deletions

View File

@ -179,6 +179,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](docs/affinity-validation.md); [scope, provenance and limits](crates/affinity/README.md).
- Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it.
- Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
- 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`).
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).

View File

@ -52,6 +52,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
update
),
cmd!(
"links.updateChanged",
"Update Changed Links",
[],
None,
"{} read again the active document's linked files that changed on disk since they were last seen (size or modification time), whatever Preferences → File Handling → Update Links says; the desktop app looks every two seconds and follows that preference. Files seen for the first time are only remembered. One undo step → {updated: [ids], ask: [], modified: []}",
has_doc,
crate::link_watch::update_changed
),
cmd!(
"links.relink",
"Relink",
@ -158,6 +167,11 @@ fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option<bool>)> {
}
}
/// Every linked file of `d` with the ids of the objects showing it (for [`crate::link_watch`]).
pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec<u64>)> {
groups(d, None).into_iter().filter(|g| !g.ids.is_empty()).map(|g| (g.link.path, ids_json(&g.ids))).collect()
}
/// The linked images and placed documents of `d` by file and content: those in the layers (only `ids` when
/// given), then, without `ids`, the ones only symbols and patterns show.
fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {

View File

@ -10,6 +10,7 @@ pub mod cmd;
pub mod file_access;
pub mod guard;
pub mod inspect;
pub mod link_watch;
pub mod steps;
mod tooling;
pub mod units;
@ -873,6 +874,11 @@ pub struct Session {
/// The walker threads a folder search starts; `None`: [`cmd::findfiles::threads`]. Tests set
/// it.
pub search_threads: Option<usize>,
/// Each open document's linked files' size and modification time when last seen (linked
/// files changing while the document is open, [`link_watch`]).
pub link_stamps: std::collections::HashMap<(u64, String), link_watch::Stamp>,
/// The stamps being taken on a worker thread ([`Session::start_link_scan`]).
pub link_scan: Option<link_watch::LinkScan>,
}
impl Default for Session {
@ -928,6 +934,8 @@ impl Session {
font_search: None,
search_rules: None,
search_threads: None,
link_stamps: Default::default(),
link_scan: None,
}
}
@ -1506,6 +1514,8 @@ mod tests_links;
#[cfg(test)]
mod tests_linkspanel;
#[cfg(test)]
mod tests_linkwatch;
#[cfg(test)]
mod tests_liquify;
#[cfg(test)]
mod tests_live;

View File

@ -0,0 +1,150 @@
//! Linked files that change while their document is open (another app saved them: PhotoCraft,
//! LightCraft, Photoshop, a placed VectorCraft document): Preferences › File Handling › Update
//! Links decides, as it does when a document opens. Automatically reads them again
//! (`links.update`, an undo step), Ask When Modified offers to, Manually leaves them modified in
//! the Links panel.
//!
//! The active document's linked files are compared by size and modification time with what they
//! were when last seen. The desktop app takes the stamps every two seconds and when its window
//! comes back to the front, on a worker thread so a file on a slow network share never holds up
//! the interface ([`Session::start_link_scan`], [`Session::poll_link_scan`]);
//! `links.updateChanged` takes them on the spot and reads the changed files again (CLI, agents).
//!
//! Stamps are kept per document: one in the background is looked at when it comes to the front.
//! A file seen for the first time is only remembered (one modified while its document was closed
//! is handled when the document opens); one that can't be read yet (another app is still writing
//! it) keeps its old stamp, so the next look tries again. A change is acted on once: undoing an
//! update, or answering No, leaves the link modified until the file changes again.
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::{Arc, Mutex, PoisonError};
use serde_json::{Value, json};
use crate::{Result, Session};
/// A file's size and modification time (nanoseconds since 1970).
pub type Stamp = (u64, u128);
/// Stamps of a document's linked files taken on a worker thread: (document uid, [(file,
/// object ids, stamp)]).
pub type LinkScan = Arc<Mutex<Option<(u64, Vec<(String, Vec<u64>, Option<Stamp>)>)>>>;
#[cfg(not(target_arch = "wasm32"))]
fn stamp(path: &str) -> Option<Stamp> {
let meta = std::fs::metadata(path).ok()?;
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
Some((meta.len(), modified.as_nanos()))
}
#[cfg(target_arch = "wasm32")]
fn stamp(_: &str) -> Option<Stamp> {
None
}
/// The linked files of `d` with the ids of the objects showing them.
pub fn linked_files(d: &vectorcraft_doc::Document) -> Vec<(String, Vec<u64>)> {
crate::cmd::links::linked_groups(d)
}
/// `links.updateChanged`: stamp the active document's linked files now and read the changed
/// ones again, whatever Update Links says.
pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let (uid, files) = (st.uid, crate::cmd::links::linked_groups(&st.doc));
let now = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
s.apply_link_stamps(uid, now, true)
}
impl Session {
/// Start stamping the active document's linked files on a worker thread, unless a look is
/// still running (or the build has no files to look at: the web app).
pub fn start_link_scan(&mut self) {
if self.link_scan.is_some() || cfg!(target_arch = "wasm32") {
return;
}
let Some(st) = self.active() else { return };
let (uid, files) = (st.uid, crate::cmd::links::linked_groups(&st.doc));
if files.is_empty() {
return;
}
let slot: LinkScan = Arc::default();
let out = slot.clone();
let spawned = std::thread::Builder::new().name("link-watch".into()).spawn(move || {
let stamps = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
*out.lock().unwrap_or_else(PoisonError::into_inner) = Some((uid, stamps));
});
if spawned.is_ok() {
self.link_scan = Some(slot);
}
}
/// Apply a finished look (`None` while there is none, or it is still running): see
/// [`Session::apply_link_stamps`].
pub fn poll_link_scan(&mut self) -> Option<Result<Value>> {
let (uid, stamps) = self.link_scan.as_ref()?.lock().unwrap_or_else(PoisonError::into_inner).take()?;
self.link_scan = None;
Some(self.apply_link_stamps(uid, stamps, false))
}
/// Compare the stamps of document `uid`'s linked files with the ones last seen and act on
/// the changed ones as Update Links says (`update`: read them again regardless). Returns
/// `{updated: [ids], ask: [ids], modified: [ids]}`: read again, to offer (Ask When Modified),
/// or left modified (Manually).
pub fn apply_link_stamps(&mut self, uid: u64, now: Vec<(String, Vec<u64>, Option<Stamp>)>, update: bool) -> Result<Value> {
let none = || json!({"updated": [], "ask": [], "modified": []});
// The document went to the back meanwhile: it's looked at when it's in front again.
if self.active().map(|d| d.uid) != Some(uid) {
return Ok(none());
}
// This document's last stamps; those of closed documents are dropped.
let open: HashSet<u64> = self.documents().iter().map(|d| d.uid).collect();
let mut before: HashMap<String, Stamp> = HashMap::new();
self.link_stamps.retain(|(u, p), s| {
if *u == uid {
before.insert(p.clone(), *s);
false
} else {
open.contains(u)
}
});
let mut changed: Vec<(String, Vec<u64>)> = Vec::new();
for (path, ids, stamp) in now {
match (before.get(&path), stamp) {
// Not there right now (an app saving by delete and rename): keep the last stamp.
(Some(old), None) => {
self.link_stamps.insert((uid, path), *old);
}
(old, Some(new)) => {
if old.is_some_and(|o| *o != new) {
changed.push((path.clone(), ids));
}
self.link_stamps.insert((uid, path), new);
}
(None, None) => {}
}
}
if changed.is_empty() {
return Ok(none());
}
let ids: Vec<u64> = changed.iter().flat_map(|(_, ids)| ids.iter().copied()).collect();
let mode = if update { "automatically" } else { self.prefs.update_links.as_str() };
match mode {
"automatically" => {
let r = self.execute("links.update", &json!({"ids": ids}))?;
// Not readable yet (still being written): the next look tries again.
let unread: BTreeSet<u64> = r["missing"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
for (path, ids) in &changed {
if ids.iter().any(|i| unread.contains(i))
&& let Some(old) = before.get(path)
{
self.link_stamps.insert((uid, path.clone()), *old);
}
}
Ok(json!({"updated": r["updated"], "ask": [], "modified": []}))
}
"askWhenModified" => Ok(json!({"updated": [], "ask": ids, "modified": []})),
_ => Ok(json!({"updated": [], "ask": [], "modified": ids})),
}
}
}

View File

@ -0,0 +1,88 @@
//! Linked files changing while their document is open (`link_watch`): `links.updateChanged`,
//! and the background look following Preferences → File Handling → Update Links.
use serde_json::{Value, json};
use super::*;
use crate::tests_links::{BLUE, Folder, RED, centre_colour, place, png, session};
/// Write `bytes` to `path` so its modification time moves even on coarse file systems.
fn save(path: &str, bytes: &[u8]) {
std::thread::sleep(std::time::Duration::from_millis(30));
std::fs::write(path, bytes).unwrap();
}
fn colour(s: &Session) -> [u8; 3] {
centre_colour(&s.doc().unwrap().doc)
}
/// One background look, waited for.
fn look(s: &mut Session) -> Value {
s.start_link_scan();
let t0 = std::time::Instant::now();
loop {
if let Some(r) = s.poll_link_scan() {
return r.unwrap();
}
assert!(t0.elapsed().as_secs() < 10, "the look never finished");
std::thread::yield_now();
}
}
#[test]
fn update_changed_reads_changed_files_again_once() {
let dir = Folder::new("watch-update");
let path = dir.file("art.png");
std::fs::write(&path, png(40, 40, RED)).unwrap();
let mut s = session();
let id = place(&mut s, &path);
// First look: remembered, nothing to read.
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([]));
save(&path, &png(40, 40, BLUE));
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([id.0]));
assert_eq!(colour(&s), BLUE);
// Undoing it is respected: the link stays modified until the file changes again.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(colour(&s), RED);
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([]));
assert_eq!(s.execute("links.check", &json!({})).unwrap()["modified"], 1);
}
#[test]
fn a_background_look_follows_update_links() {
for (mode, key) in [("automatically", "updated"), ("askWhenModified", "ask"), ("manually", "modified")] {
let dir = Folder::new(&format!("watch-{mode}"));
let path = dir.file("art.png");
std::fs::write(&path, png(40, 40, RED)).unwrap();
let mut s = session();
s.prefs.update_links = mode.into();
let id = place(&mut s, &path);
assert_eq!(look(&mut s), json!({"updated": [], "ask": [], "modified": []}), "{mode}");
save(&path, &png(40, 40, BLUE));
let r = look(&mut s);
assert_eq!(r[key], json!([id.0]), "{mode}: {r}");
assert_eq!(colour(&s), if mode == "automatically" { BLUE } else { RED }, "{mode}");
// Acted on once: the next look finds nothing new.
assert_eq!(look(&mut s), json!({"updated": [], "ask": [], "modified": []}), "{mode}");
assert!(s.link_scan.is_none());
}
}
#[test]
fn a_file_still_being_written_is_tried_again() {
let dir = Folder::new("watch-partial");
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);
// Another app has only written the start of the file so far.
let full = png(40, 40, BLUE);
save(&path, &full[..16]);
assert_eq!(look(&mut s)["updated"], json!([]));
assert_eq!(colour(&s), RED, "the last good pixels stay");
save(&path, &full);
assert_eq!(look(&mut s)["updated"], json!([id.0]));
assert_eq!(colour(&s), BLUE);
}

View File

@ -2,8 +2,8 @@
//! (`links.relink`), and with Apply to All the other missing files are looked for by name in its
//! folder; Ignore keeps the images showing their saved preview, and with Apply to All ignores the
//! rest. Afterwards, with Preferences › Update Links: Ask When Modified, the modified links are
//! offered for update (`links.update`). Files changed while the app was in the background are
//! checked when its window comes back to the front ([`after_focus`]).
//! offered for update (`links.update`). Files another app changes while the document is open are
//! offered the same way ([`ask_update_changed`], from [`vectorcraft_engine::link_watch`]).
//!
//! Fields: `missing` (`[{name, path, ids}]`, the one asked about first), `modified` (the ids of
//! images whose file was modified), `applyToAll`, `path` (the replacement file; picked when empty)
@ -61,6 +61,16 @@ fn open(app: &mut VectorcraftApp, missing: Vec<Value>, modified: Vec<Value>) {
/// With Update Links: Ask When Modified, offer to read the modified linked files `ids` again.
fn ask_update(app: &mut VectorcraftApp, ids: Vec<Value>) {
ask(app, ids, tl!("{count} linked image(s) changed since this document was saved. Show the new versions?"));
}
/// The same offer for linked files another app changed while the document is open
/// ([`vectorcraft_engine::link_watch`]).
pub(crate) fn ask_update_changed(app: &mut VectorcraftApp, ids: Vec<Value>) {
ask(app, ids, tl!("{count} linked image(s) changed on disk. Show the new versions?"));
}
fn ask(app: &mut VectorcraftApp, ids: Vec<Value>, question: &str) {
if ids.is_empty() {
return;
}
@ -68,46 +78,10 @@ fn ask_update(app: &mut VectorcraftApp, ids: Vec<Value>) {
app.status(format!("{} linked image(s) changed on disk: Update Links shows the new versions", ids.len()));
return;
}
let detail = crate::i18n::fmt(
tl!("{count} linked image(s) changed since this document was saved. Show the new versions?"),
&[("count", &ids.len().to_string())],
);
let detail = crate::i18n::fmt(question, &[("count", &ids.len().to_string())]);
super::confirm::ask(app, tl!("Update Modified Links"), &detail, "links.update", json!({ "ids": ids }));
}
/// A linked file as last seen: its path, and its size and modification time when it exists.
pub(crate) type FileStamp = (String, Option<(u64, Option<std::time::SystemTime>)>);
/// The window came back to the front (#925): linked files of the active document changed
/// meanwhile are read again with Update Links: Automatically, offered for update with Ask When
/// Modified (once per change of a file), and left to the Links panel with Manually. Nothing is
/// asked while a dialog is open.
pub fn after_focus(app: &mut VectorcraftApp) {
if cfg!(target_arch = "wasm32") || app.session.prefs.update_links == "manually" || app.ui.dialog.is_some() || app.session.active().is_none() {
return;
}
let Ok(r) = app.session.execute("links.check", &json!({})) else { return };
let rows: Vec<Value> = r["links"].as_array().into_iter().flatten().filter(|l| l["status"] == "modified").cloned().collect();
if rows.is_empty() {
return;
}
if app.session.prefs.update_links == "automatically" {
// A failure shows in the status bar, as the Links panel's Update Link does.
let _ = app.run("links.update", json!({ "ids": ids(&rows) }));
return;
}
// The file each link reads (one found elsewhere after a move), its size and when it was last
// changed (two changes can share a time on file systems that keep it coarsely).
let stamp = |l: &Value| -> FileStamp {
let path = l["found"].as_str().or(l["path"].as_str()).unwrap_or_default().to_string();
let meta = std::fs::metadata(&path).ok();
let at = meta.as_ref().map(|m| (m.len(), m.modified().ok()));
(path, at)
};
let new: Vec<Value> = rows.into_iter().filter(|l| app.links_asked.insert(stamp(l))).collect();
ask_update(app, ids(&new));
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let missing = d.fields.get("missing").and_then(Value::as_array).map_or(&[][..], Vec::as_slice);
@ -296,58 +270,4 @@ mod tests {
assert!(app.ui.dialog.is_none());
let _ = std::fs::remove_dir_all(dir);
}
/// #925: a linked file changed while the app was in the background follows Update Links when
/// the window comes back to the front.
#[test]
fn a_link_changed_in_the_background_follows_update_links_on_focus() {
let dir = std::env::temp_dir().join(format!("vectorcraft-focus-links-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let pic = dir.join("a.png");
// Each version a different size, so no two share a length and modification time.
let mut version = 0;
let mut change = || {
version += 1;
let mut out = vec![];
image::RgbaImage::from_pixel(100 + version, 50, image::Rgba([40 * version as u8, 0, 0, 255]))
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
std::fs::write(&pic, out).unwrap();
};
change();
app.run("file.place", json!({"path": pic.to_string_lossy()})).unwrap();
let modified = |app: &mut VectorcraftApp| app.run("links.check", json!({})).unwrap()["modified"].as_u64().unwrap();
// The window coming to the front asks for the check.
let mut raw = egui::RawInput { events: vec![egui::Event::WindowFocused(true)], ..Default::default() };
app.raw_input_hook(&mut raw);
assert!(app.links_check);
// Ask When Modified (the default): asked once per change.
change();
after_focus(&mut app);
let d = app.ui.dialog.take().expect("asks");
assert_eq!(d.str("__command"), "links.update");
after_focus(&mut app);
assert!(app.ui.dialog.is_none(), "not twice for the same change");
change();
after_focus(&mut app);
assert!(app.ui.dialog.is_some(), "a new change asks again");
super::super::confirm(&mut app).unwrap();
assert_eq!(modified(&mut app), 0);
// Automatically: read again without asking.
app.run("prefs.set", json!({"key": "updateLinks", "value": "automatically"})).unwrap();
change();
after_focus(&mut app);
assert!(app.ui.dialog.is_none());
assert_eq!(modified(&mut app), 0);
// Manually: left to the Links panel.
app.run("prefs.set", json!({"key": "updateLinks", "value": "manually"})).unwrap();
change();
after_focus(&mut app);
assert!(app.ui.dialog.is_none());
assert_eq!(modified(&mut app), 1);
let _ = std::fs::remove_dir_all(dir);
}
}

View File

@ -516,6 +516,9 @@
Layers Vrstvy
Libraries Knihovny
Links Propojení
{count} linked image(s) changed on disk. Show the new versions? Na disku se změnilo {count} propojených obrázků. Zobrazit nové verze?
Update Changed Links Aktualizovat změněná propojení
@msg Updated {_1} linked file(s) changed on disk Aktualizováno propojených souborů změněných na disku: {_1}
Magic Wand Kouzelná hůlka
Navigator Navigátor
Pattern Options Možnosti vzorku

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3003,6 +3003,7 @@
Untitled Sin título
Update Link Actualizar vínculo
Update Links Actualizar vínculos
Update Changed Links Actualizar vínculos cambiados
Update Modified Links Actualizar vínculos modificados
Updated {} image(s) Se actualizaron {} imagen(es)
UPPERCASE MAYÚSCULAS
@ -3138,6 +3139,7 @@
{count} linked file(s) not found No se encontraron {count} archivo(s) vinculado(s)
fonts not copied: {fonts} fuentes no copiadas: {fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? {count} imagen(es) vinculada(s) cambiaron desde que se guardó este documento. ¿Mostrar las versiones nuevas?
{count} linked image(s) changed on disk. Show the new versions? {count} imagen(es) vinculada(s) cambiaron en el disco. ¿Mostrar las versiones nuevas?
{count} more rules: Export writes every one. {count} reglas más: Exportar las escribe todas.
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. Se omitieron {count} objetos sin nombre: ponles nombre o activa Objetos sin nombre en el menú del panel.
{effect} is already applied {effect} ya está aplicado
@ -3788,6 +3790,7 @@ axis Both Ambos
@msg {_1} is already used by {_2} {_1} ya lo usa {_2}
@msg {_1} linked file(s) can't be read here: their previews show aquí no se pueden leer {_1} archivo(s) vinculado(s): se muestran sus previsualizaciones
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} imagen(es) vinculada(s) cambiaron en el disco: Actualizar vínculos muestra las versiones nuevas
@msg Updated {_1} linked file(s) changed on disk Se actualizaron {_1} archivo(s) vinculado(s) que cambiaron en el disco
@msg {_1}: not saved, so not closed {_1}: no se guardó, así que no se cerró
@msg {bpp}-bit bitmaps can't be read no se pueden leer mapas de bits de {bpp} bits
@msg {command} didn't create an object {command} no creó ningún objeto

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3000,6 +3000,7 @@
Untitled Sans titre
Update Link Mettre à jour le lien
Update Links Mettre à jour les liens
Update Changed Links Mettre à jour les liens modifiés
Update Modified Links Mettre à jour les liens modifiés
Updated {} image(s) {} image(s) mise(s) à jour
UPPERCASE MAJUSCULES
@ -3135,6 +3136,7 @@
{count} linked file(s) not found {count} fichier(s) lié(s) introuvable(s)
fonts not copied: {fonts} polices non copiées : {fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? {count} image(s) liée(s) modifiée(s) depuis l’enregistrement de ce document. Afficher les nouvelles versions ?
{count} linked image(s) changed on disk. Show the new versions? {count} image(s) liée(s) modifiée(s) sur le disque. Afficher les nouvelles versions ?
{count} more rules: Export writes every one. {count} règles de plus : Exporter les écrit toutes.
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} objets sans nom omis : nommez-les ou activez Objets sans nom dans le menu du panneau.
{effect} is already applied {effect} est déjà appliqué
@ -3785,6 +3787,7 @@ axis Both Les deux
@msg {_1} is already used by {_2} {_1} est déjà utilisé par {_2}
@msg {_1} linked file(s) can't be read here: their previews show {_1} fichier(s) lié(s) illisible(s) ici : leurs aperçus sont affichés
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} image(s) liée(s) modifiée(s) sur le disque : Mettre à jour les liens affiche les nouvelles versions
@msg Updated {_1} linked file(s) changed on disk {_1} fichier(s) lié(s) modifié(s) sur le disque mis à jour
@msg {_1}: not saved, so not closed {_1} : non enregistré, donc non fermé
@msg {bpp}-bit bitmaps can't be read impossible de lire les images bitmap en {bpp} bits
@msg {command} didn't create an object {command} n’a créé aucun objet

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3003,6 +3003,7 @@
Untitled Senza titolo
Update Link Aggiorna collegamento
Update Links Aggiorna collegamenti
Update Changed Links Aggiorna collegamenti modificati
Update Modified Links Aggiorna collegamenti modificati
Updated {} image(s) Immagini aggiornate: {}
UPPERCASE MAIUSCOLO
@ -3138,6 +3139,7 @@
{count} linked file(s) not found File collegati non trovati: {count}
fonts not copied: {fonts} font non copiati: {fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? Immagini collegate modificate dopo il salvataggio di questo documento: {count}. Vuoi mostrare le nuove versioni?
{count} linked image(s) changed on disk. Show the new versions? Immagini collegate modificate su disco: {count}. Vuoi mostrare le nuove versioni?
{count} more rules: Export writes every one. Altre {count} regole: Esporta le scrive tutte.
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} oggetti senza nome esclusi: assegna loro un nome o attiva Oggetti senza nome nel menu del pannello.
{effect} is already applied L'effetto {effect} è già applicato
@ -3788,6 +3790,7 @@ axis Both Entrambi
@msg {_1} is already used by {_2} {_1} è già usato da {_2}
@msg {_1} linked file(s) can't be read here: their previews show file collegati non leggibili qui: {_1}; vengono mostrate le loro anteprime
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions immagini collegate modificate su disco: {_1}; Aggiorna collegamenti mostra le nuove versioni
@msg Updated {_1} linked file(s) changed on disk File collegati modificati su disco aggiornati: {_1}
@msg {_1}: not saved, so not closed {_1}: non salvato, quindi non chiuso
@msg {bpp}-bit bitmaps can't be read impossibile leggere bitmap a {bpp} bit
@msg {command} didn't create an object {command} non ha creato alcun oggetto

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2668,6 +2668,7 @@
Unshift シフトを戻す
Update Link リンクを更新
Update Links リンクを更新
Update Changed Links 変更されたリンクを更新
Update Modified Links 変更されたリンクを更新
Use メニュー
Use Artboards アートボードを使用
@ -2777,6 +2778,7 @@
{count} linked file(s) not found リンクファイルが {count} 個見つかりません
fonts not copied: {fonts} コピーされなかったフォント:{fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? 保存後に {count} 個のリンク画像が変更されています。新しいバージョンを表示しますか?
{count} linked image(s) changed on disk. Show the new versions? {count} 個のリンク画像がディスク上で変更されました。新しいバージョンを表示しますか?
{count} more rules: Export writes every one. ほかに {count} 個のルールがあります。書き出しにはすべて含まれます。
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. 名前のないオブジェクト {count} 個を除外しました。名前を付けるか、パネルメニューで「名前のないオブジェクト」をオンにしてください。
{effect} is already applied {effect} はすでに適用されています
@ -3719,6 +3721,7 @@ theme Appearance 外観
@msg `{tool}` isn't a Liquify tool `{tool}` はゆがみツールではありません
@msg {_1} linked file(s) can't be read here: their previews show この環境では {_1} 個のリンクファイルを読み込めません: 代わりにプレビューを表示しています
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} 個のリンク画像がディスク上で変更されました: 「リンクを更新」で新しいバージョンを表示できます
@msg Updated {_1} linked file(s) changed on disk ディスク上で変更された {_1} 個のリンクファイルを更新しました
@msg pick a colour, gradient or pattern to save as a swatch スウォッチとして保存するカラー、グラデーション、またはパターンを選択してください
@msg Packaged {n} file(s) in {folder}{note} {n} 個のファイルを {folder} にパッケージしました{note}
@msg Packaged {n} file(s){note} {n} 個のファイルをパッケージしました{note}

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2483,6 +2483,9 @@
Unshift Desfazer Mudança de Cor
Update Link Atualizar Link
Update Links Atualizar Links
Update Changed Links Atualizar Links alterados
{count} linked image(s) changed on disk. Show the new versions? {count} imagem(ns) vinculada(s) alterada(s) no disco. Mostrar as novas versões?
@msg Updated {_1} linked file(s) changed on disk {_1} arquivo(s) vinculado(s) alterado(s) no disco atualizado(s)
Update Modified Links Atualizar Links Modificados
Use Use
Use Artboards Usar Mesas de Trabalho

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2999,6 +2999,7 @@
Untitled Без названия
Update Link Обновить связь
Update Links Обновить связи
Update Changed Links Обновить измененные связи
Update Modified Links Обновить изменённые связи
Updated {} image(s) Обновлено изображений: {}
UPPERCASE ПРОПИСНЫЕ
@ -3134,6 +3135,7 @@
{count} linked file(s) not found Не найдено связанных файлов: {count}
fonts not copied: {fonts} шрифты не скопированы: {fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? Связанных изображений ({count}) изменились с момента сохранения этого документа. Показать новые версии?
{count} linked image(s) changed on disk. Show the new versions? Связанных изображений ({count}) изменились на диске. Показать новые версии?
{count} more rules: Export writes every one. Ещё правил: {count}: экспорт записывает все.
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. Пропущено объектов без имени: {count}: назовите их или включите «Объекты без имени» в меню панели.
{effect} is already applied {effect} уже применён
@ -3784,6 +3786,7 @@ axis Both Оба
@msg {_1} is already used by {_2} {_1} уже используется {_2}
@msg {_1} linked file(s) can't be read here: their previews show здесь невозможно прочитать связанных файлов: {_1}: отображаются их предварительные просмотры
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions связанных изображений ({_1}) изменились на диске: «Обновить связи» покажет новые версии
@msg Updated {_1} linked file(s) changed on disk Обновлено связанных файлов, измененных на диске: {_1}
@msg {_1}: not saved, so not closed {_1}: не сохранён, поэтому не закрыт
@msg {bpp}-bit bitmaps can't be read растровые изображения с глубиной {bpp} бит не читаются
@msg {command} didn't create an object {command} не создал объект

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2995,6 +2995,7 @@
Untitled Без назви
Update Link Оновити зв’язок
Update Links Оновити зв’язки
Update Changed Links Оновити змінені зв’язки
Update Modified Links Оновити змінені зв’язки
Updated {} image(s) Оновлено зображень: {}
UPPERCASE ВЕЛИКІ ЛІТЕРИ
@ -3130,6 +3131,7 @@
{count} linked file(s) not found не знайдено пов’язаних файлів: {count}
fonts not copied: {fonts} шрифти не скопійовано: {fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? пов’язані зображення ({count}) змінилися з моменту збереження цього документа. Показати нові версії?
{count} linked image(s) changed on disk. Show the new versions? пов’язані зображення ({count}) змінилися на диску. Показати нові версії?
{count} more rules: Export writes every one. ще правил: {count}: експорт записує все.
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. пропущено об’єкти без імені: {count}: назвіть їх або увімкніть «Об’єкти без імені» у меню панелі.
{effect} is already applied {effect} вже застосований
@ -3771,6 +3773,7 @@ axis Both Обидві
@msg {_1} is already used by {_2} {_1} вже використовується {_2}
@msg {_1} linked file(s) can't be read here: their previews show тут неможливо прочитати пов’язані файли: {_1}: відображаються їх попередні перегляди
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions пов’язаних зображень ({_1}) змінилися на диску: «Оновити зв’язки» покаже нові версії
@msg Updated {_1} linked file(s) changed on disk Оновлено пов’язаних файлів, змінених на диску: {_1}
@msg {_1}: not saved, so not closed {_1}: не збережено, тому не закрито
@msg {bpp}-bit bitmaps can't be read растрові зображення з глибиною {bpp} біт не читаються
@msg {command} didn't create an object {command} не створив об’єкт

Can't render this file because it has a wrong number of fields in line 13.

View File

@ -2893,6 +2893,9 @@
Untitled 未命名
Update Link 更新链接
Update Links 更新链接
Update Changed Links 更新已更改的链接
{count} linked image(s) changed on disk. Show the new versions? 有 {count} 个链接图像已在磁盘上更改。要显示新版本吗?
@msg Updated {_1} linked file(s) changed on disk 已更新 {_1} 个在磁盘上更改的链接文件
Update Modified Links 更新已修改的链接
Updated {} image(s) 已更新 {} 张图像
UPPERCASE 大写

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2984,6 +2984,9 @@
Untitled 未命名
Update Link 更新連結
Update Links 更新連結
Update Changed Links 更新已變更的連結
{count} linked image(s) changed on disk. Show the new versions? 有 {count} 個連結影像已在磁碟上變更。要顯示新版本嗎?
@msg Updated {_1} linked file(s) changed on disk 已更新 {_1} 個在磁碟上變更的連結檔案
Update Modified Links 更新已修改的連結
Updated {} image(s) 已更新 {} 張影像
UPPERCASE 全部大寫

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -371,12 +371,6 @@ pub struct VectorcraftApp {
/// The look for fonts installed or removed while the app was in the background, running
/// ([`Self::refresh_installed_fonts`]): whether they were.
font_check: Option<std::sync::mpsc::Receiver<bool>>,
/// The window came back to the front: check the active document's linked files next frame
/// ([`dialogs::missing_links::after_focus`]).
pub(crate) links_check: bool,
/// The changed linked files already offered for update (path, size and modification time), so
/// coming back to the window doesn't ask about the same change twice.
pub(crate) links_asked: std::collections::HashSet<dialogs::missing_links::FileStamp>,
/// Keyboard pastes of something other than text (see [`shortcuts::PasteChord`]).
pub(crate) paste_chord: shortcuts::PasteChord,
/// Saves and exports running in the background (Preferences → File Handling).
@ -474,8 +468,6 @@ impl VectorcraftApp {
clipboard_probe: None,
picks: picks::Picks::default(),
font_check: None,
links_check: false,
links_asked: Default::default(),
paste_chord: Default::default(),
background: Default::default(),
recovery: Default::default(),
@ -757,6 +749,54 @@ impl VectorcraftApp {
std::mem::take(&mut self.ime_discard)
}
/// Linked files another app changed while their document is open: every two seconds, and as
/// soon as the window comes back to the front, stamp the active document's linked files on a
/// worker thread and act on the changed ones as Preferences › File Handling › Update Links
/// says ([`vectorcraft_engine::link_watch`]). No look starts while a dialog is open, so an
/// Ask When Modified question never goes unseen.
#[cfg(not(target_arch = "wasm32"))]
fn tick_link_updates(&mut self, ctx: &egui::Context, now: f64) {
if let Some(r) = self.session.poll_link_scan() {
match r {
Ok(r) => {
let n = |k: &str| r[k].as_array().map_or(0, Vec::len);
if n("updated") > 0 {
self.status(format!("Updated {} linked file(s) changed on disk", n("updated")));
}
if n("ask") > 0 {
dialogs::missing_links::ask_update_changed(self, r["ask"].as_array().cloned().unwrap_or_default());
}
if n("modified") > 0 {
self.status(format!("{} linked image(s) changed on disk: Update Links shows the new versions", n("modified")));
}
}
Err(e) => self.status(e.to_string()),
}
}
let (last_key, focus_key) = (egui::Id::new("linkWatch.lastScan"), egui::Id::new("linkWatch.focused"));
let focused = ctx.input(|i| i.focused);
let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused);
ctx.data_mut(|d| d.insert_temp(focus_key, focused));
if self.ui.dialog.is_some() || self.session.active().is_none() {
return;
}
let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(f64::NEG_INFINITY);
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
// wake the app for looks of its own (any frame still looks once one is due).
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));
}
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::<bool>(egui::Id::new("linkWatch.links"))).unwrap_or(false) {
ctx.request_repaint_after(std::time::Duration::from_millis(2000));
}
}
/// Show a transient status message.
pub fn status(&mut self, s: impl Into<String>) {
self.ui.status = s.into();
@ -889,11 +929,10 @@ impl VectorcraftApp {
self.system_paste = wanted && self.system_clipboard_pasteable();
}
self.poll_font_check(ctx);
if std::mem::take(&mut self.links_check) {
dialogs::missing_links::after_focus(self);
}
picks::poll(self, ctx);
background::poll(self);
#[cfg(not(target_arch = "wasm32"))]
self.tick_link_updates(ctx, now);
if !self.background.jobs.is_empty() {
// Keep the status bar's progress moving and pick the result up when it arrives.
ctx.request_repaint_after(std::time::Duration::from_millis(100));
@ -1025,10 +1064,7 @@ impl VectorcraftApp {
match e {
egui::Event::ModifiersChanged(m) => self.host_modifiers = *m,
egui::Event::WindowFocused(false) => self.host_modifiers = egui::Modifiers::NONE,
egui::Event::WindowFocused(true) => {
self.refresh_installed_fonts();
self.links_check = true;
}
egui::Event::WindowFocused(true) => self.refresh_installed_fonts(),
_ => {}
}
}