Open: the opening thread also reads linked files and lays out type

When the desktop app opened a document on a thread of its own, that
thread parsed it, but open_read, which the app ran on the UI thread,
read the document's linked files, decoding BMP, PSD and RGB TIFF files
and storing them again as PNG, and laid out the type. In release builds,
a document with 12 linked 3000 x 2000 TIFFs kept the UI thread busy for
1.04 s, and one with 300 Auto Size area type objects, every second one
in three columns, kept it busy for 0.85 s.

read_for_open now reads the linked files and lays out the type on the
opening thread, with the path and the Update Links preference that
opening::start passes it, and open_read only adds the finished document.
open_read now takes 0.02 ms and 0.1 ms on those documents. For the
measured documents, the opened documents, results and undo states are
the same as on main.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo authored and bflatastic committed 2026-10-14 04:56:30 +07:00
1 parent 37174ac306
commit 85cea7efea
5 files changed
+162 -22

No files matched your search

+34 -13
View File
@@ -9,6 +9,7 @@ use vectorcraft_geom::Affine;
use super::super::*;
use super::pdfimport::LoadOptions;
use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, format_for_name, psdread, read_file};
use crate::cmd::links::Resolved;
use crate::{EngineError, Prefs};
/// A file read into a document, with its format and non-fatal import notes.
@@ -292,47 +293,67 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option<String
/// [`open_bytes`] with the `document.open` options in `p` ([`LoadOptions::from_params`]).
pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<Value> {
open_read(s, read_for_open(name, bytes, p)?, path)
let update_links = s.prefs.update_links == "automatically";
open_read(s, read_for_open(name, bytes, path, p, update_links)?)
}
/// A file read for `document.open` but not opened yet ([`read_for_open`], [`open_read`]).
pub struct ReadFile {
loaded: Loaded,
opts: LoadOptions,
/// Where the file was read from.
path: Option<String>,
/// What became of its linked files.
links: Resolved,
}
/// The first half of [`open_bytes_with`]: read and decode the file, which needs no session (an
/// app may do it on another thread) → what [`open_read`] opens.
pub fn read_for_open(name: &str, bytes: &[u8], p: &Value) -> Result<ReadFile> {
#[cfg(test)]
impl ReadFile {
/// The document as it was read, before it opens.
pub(crate) fn document(&self) -> &Document {
&self.loaded.doc
}
}
/// The first half of [`open_bytes_with`], which needs no session (an app may do it on another
/// thread): read and decode the file, read its linked files and lay out its type → what
/// [`open_read`] opens. The linked files are looked for from `path`, where the file was read from,
/// and modified ones are read again when `update_links` (Preferences › File Handling › Update
/// Links set to Automatically; see [`crate::cmd::links::resolve`]).
pub fn read_for_open(name: &str, bytes: &[u8], path: Option<String>, p: &Value, update_links: bool) -> Result<ReadFile> {
let opts = LoadOptions::from_params("document.open", p)?;
Ok(ReadFile { loaded: load_with(name, bytes, &opts)?, opts })
let mut loaded = load_with(name, bytes, &opts)?;
let links = crate::cmd::links::resolve(&mut loaded.doc, path.as_deref(), update_links);
Session::refresh_text_bounds(&mut loaded.doc);
Ok(ReadFile { loaded, opts, path, links })
}
/// The second half of [`open_bytes_with`]: open a file [`read_for_open`] read as the new active
/// document (`path`: where it was read from) → `document.open`'s result.
pub fn open_read(s: &mut Session, file: ReadFile, path: Option<String>) -> Result<Value> {
open_loaded(s, file.loaded, path, &file.opts, false)
/// document → `document.open`'s result.
pub fn open_read(s: &mut Session, file: ReadFile) -> Result<Value> {
open_loaded(s, file.loaded, file.links, file.path, &file.opts, false)
}
/// File → New from Template: any readable file (at `path`, if it is one: its links are looked for
/// from there) as a new untitled document.
pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&str>) -> Result<Value> {
let loaded = load(name, bytes)?;
let mut loaded = load(name, bytes)?;
if loaded.preview_only {
return Err(err(
"only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first",
));
}
open_loaded(s, loaded, path.map(str::to_string), &LoadOptions::default(), true)
let links = crate::cmd::links::resolve(&mut loaded.doc, path, s.prefs.update_links == "automatically");
open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true)
}
/// Make a loaded file the new active document; `opts` are the options it was read with.
fn open_loaded(s: &mut Session, loaded: Loaded, path: Option<String>, opts: &LoadOptions, as_template: bool) -> Result<Value> {
/// Make a loaded file the new active document; `links` is what became of its linked files, and
/// `opts` are the options it was read with.
fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<String>, opts: &LoadOptions, as_template: bool) -> Result<Value> {
let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded.with_grid_prefs(&s.prefs);
// What reading the file left out of the document, if anything.
let losses: Vec<String> = warnings.iter().filter(|w| is_loss(w)).cloned().collect();
let source_path = path.clone().filter(|_| !losses.is_empty());
let links = crate::cmd::links::resolve(&mut doc, path.as_deref(), s.prefs.update_links == "automatically");
// A template (saved by Save as Template, or an .ait/.vctemplate file) opens as a new untitled
// document.
let template = as_template || doc.template || matches!(format.id, "ait" | "template");
+1 -1
View File
@@ -1223,7 +1223,7 @@ impl Session {
}
/// Text layout bounds are a cache (not saved): compute them for a document just read, or
/// selection boxes and hit testing would use the rough estimate until each text is edited.
fn refresh_text_bounds(doc: &mut Document) {
pub(crate) fn refresh_text_bounds(doc: &mut Document) {
// Inline graphics in text take their size from their symbols' art (not saved either).
doc.resolve_inline_art();
let mut texts = vec![];
+50
View File
@@ -257,6 +257,56 @@ fn a_modified_link_follows_the_update_links_preference() {
assert!(s.doc().unwrap().history.undo.is_empty(), "opening is not an undo step");
}
/// The half of opening that the desktop app runs on a thread of its own (`read_for_open`) reads
/// the linked files and lays out the type. The half left for the UI thread (`open_read`) reads no
/// file: with a linked file moved away in between, it opens the document that `document.open` opens.
#[test]
fn a_file_read_for_opening_has_its_links_read_and_its_type_laid_out() {
let dir = Folder::new("read-for-open");
let tif = dir.file("art/photo.tif");
let mut tiff = vec![];
image::RgbImage::from_pixel(600, 300, image::Rgb(RED)).write_to(&mut std::io::Cursor::new(&mut tiff), image::ImageFormat::Tiff).unwrap();
write(&tif, &tiff);
let (gone, changed) = (dir.file("gone.png"), dir.file("changed.png"));
write(&gone, &png(300, 100, BLUE));
write(&changed, &png(300, 100, BLUE));
let mut s = session();
let photo = place(&mut s, &tif);
place(&mut s, &gone);
place(&mut s, &changed);
let area = json!({"width": 150, "height": 20, "fit": "autoHeight"});
let r = s.execute("text.create", &json!({"x": 20, "y": 40, "text": "Linked files and type. ".repeat(12), "area": area})).unwrap();
let text = NodeId(r["id"].as_u64().unwrap());
s.execute("text.areaOptions", &json!({"ids": [text.0], "columns": 2})).unwrap();
let doc_path = dir.file("poster.vectorcraft");
save(&mut s, &doc_path);
std::fs::remove_file(&gone).unwrap();
write(&changed, &png(200, 200, RED));
let bytes = std::fs::read(&doc_path).unwrap();
let away = dir.file("away.tif");
for update in ["askWhenModified", "automatically"] {
let mut at_once = session();
at_once.execute("prefs.set", &json!({"key": "updateLinks", "value": update})).unwrap();
let expected = open(&mut at_once, &doc_path);
let rows = if update == "automatically" { &expected["updatedLinks"] } else { &expected["modifiedLinks"] };
assert!(expected["missingLinks"][0]["name"] == "gone.png" && rows[0]["name"] == "changed.png", "{update}: {expected}");
let file = cmd::fileio::read_for_open(&doc_path, &bytes, Some(doc_path.clone()), &Value::Null, update == "automatically").unwrap();
let doc = file.document();
let NodeKind::Image(im) = &doc.node(photo).unwrap().kind else { panic!("not an image") };
assert!(!doc.images[&im.key].is_proxy(), "{update}: the TIFF is read, not the preview saved for it");
let NodeKind::Text(t) = &doc.node(text).unwrap().kind else { panic!("not type") };
assert!(t.cached_bounds.is_some(), "{update}: the type is laid out");
std::fs::rename(&tif, &away).unwrap();
let mut opened = session();
let r = cmd::fileio::open_read(&mut opened, file).unwrap();
std::fs::rename(&away, &tif).unwrap();
assert_eq!(r, expected, "{update}");
let (a, b) = (at_once.doc().unwrap(), opened.doc().unwrap());
assert!(*b.doc == *a.doc, "{update}: the same document");
assert!(b.history.undo.is_empty() && !b.is_dirty(), "{update}");
}
}
#[test]
fn links_from_older_files_load_and_keep_their_pixels() {
let dir = Folder::new("legacy");
+76 -8
View File
@@ -1,8 +1,8 @@
//! Opening documents off the UI thread (desktop): a file the user asks to open (File › Open, Open
//! Recent, the Home screen, files handed to the app) is decoded on a thread of its own while the
//! status bar shows it, and opens in a new tab once it is ready, as it would at once: the same
//! notes, recent files and errors. On wasm (no threads) and in unit tests it opens at once
//! ([`available`]).
//! Recent, the Home screen, files handed to the app) is decoded on a thread of its own, which also
//! reads its linked files and lays out its type, while the status bar shows it, and opens in a new tab
//! once it is ready, as it would at once: the same notes, recent files and errors. On wasm (no
//! threads) and in unit tests it opens at once ([`available`]).
use std::sync::Arc;
use std::sync::mpsc::{Receiver, TryRecvError, channel};
@@ -34,17 +34,24 @@ pub fn available() -> bool {
!(cfg!(target_arch = "wasm32") || cfg!(test) || std::env::var_os("VECTORCRAFT_SYNC_OPEN").is_some())
}
/// Decode `bytes`, the file at `path`, on a thread of its own → `{background: true, status}`, or
/// the bytes back when no thread could start (the caller opens the file at once).
/// Decode `bytes`, the file at `path`, read its linked files and lay out its type on a thread of its
/// own ([`fileio::read_for_open`]) → `{background: true, status}`, or the bytes back when no thread
/// could start (the caller opens the file at once).
pub fn start(app: &mut VectorcraftApp, path: &str, bytes: Vec<u8>) -> Result<Value, Vec<u8>> {
let (tx, done) = channel();
let bytes = Arc::new(bytes);
let (name, file) = (path.to_string(), bytes.clone());
// Update Links (Preferences › File Handling) applies there as it is set now.
let update_links = app.session.prefs.update_links == "automatically";
// The automation roots in force here are in force there.
let scope = ScopeHandle::capture();
let spawned = std::thread::Builder::new().name("vectorcraft-open".into()).spawn(move || {
let r = scope
.run(|| vectorcraft_engine::guard::catch_panic(|| fileio::read_for_open(&name, &file, &Value::Null).map_err(|e| e.to_string())))
.run(|| {
vectorcraft_engine::guard::catch_panic(|| {
fileio::read_for_open(&name, &file, Some(name.clone()), &Value::Null, update_links).map_err(|e| e.to_string())
})
})
.unwrap_or_else(|msg| Err(format!("internal error: {msg} (please report this bug)")));
// The file's bytes go before the document is handed over.
drop(file);
@@ -84,7 +91,7 @@ pub fn poll(app: &mut VectorcraftApp) {
/// Open a file read off the UI thread, as [`crate::io::open_reporting`] opens one read at once.
fn finish(app: &mut VectorcraftApp, path: &str, r: Result<ReadFile, String>) {
let opened = r.and_then(|file| fileio::open_read(&mut app.session, file, Some(path.to_string())).map_err(|e| e.to_string()));
let opened = r.and_then(|file| fileio::open_read(&mut app.session, file).map_err(|e| e.to_string()));
match opened {
Ok(r) => {
crate::io::opened(app, &r);
@@ -132,4 +139,65 @@ mod tests {
assert!(app.ui.status.starts_with("Couldn't open Broken.vectorcraft"), "{}", app.ui.status);
assert_ne!(app.ui.recent_files.first().map(String::as_str), Some("/art/Broken.vectorcraft"));
}
/// A file read off the UI thread opens as the document read at once. Its linked TIFF, BMP and JPEG
/// files are found beside it after its folder moved, a missing one shows its preview, a changed
/// one follows Update Links, and its Auto Size area type in two columns is fitted. Neither open
/// leaves a step to undo, and both ask the same question.
#[test]
fn a_file_read_off_the_ui_thread_has_its_links_and_type_as_one_read_at_once() {
use image::ImageFormat::{Bmp, Jpeg, Png, Tiff};
let dir = std::env::temp_dir().join(format!("vc-opening-links-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (before, after) = (dir.join("before"), dir.join("after"));
std::fs::create_dir_all(&before).unwrap();
let file = |folder: &std::path::Path, name: &str| folder.join(name).to_string_lossy().into_owned();
let picture = |format: image::ImageFormat, rgb: [u8; 3]| {
let mut out = vec![];
image::RgbImage::from_pixel(60, 40, image::Rgb(rgb)).write_to(&mut std::io::Cursor::new(&mut out), format).unwrap();
out
};
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
for (name, format) in [("a.tif", Tiff), ("b.bmp", Bmp), ("c.jpg", Jpeg), ("gone.png", Png), ("changed.png", Png)] {
std::fs::write(file(&before, name), picture(format, [200, 40, 40])).unwrap();
app.session.execute("file.place", &json!({"path": file(&before, name), "at": [200, 150]})).unwrap();
}
let area = json!({"width": 150, "height": 20, "fit": "autoHeight"});
let t = app.session.execute("text.create", &json!({"x": 20, "y": 40, "text": "Type in two columns. ".repeat(12), "area": area})).unwrap();
app.session.execute("text.areaOptions", &json!({"ids": [t["id"]], "columns": 2})).unwrap();
app.session.execute("document.save", &json!({"path": file(&before, "poster.vectorcraft")})).unwrap();
std::fs::remove_file(file(&before, "gone.png")).unwrap();
let changed = file(&before, "changed.png");
let stamp = std::fs::metadata(&changed).unwrap().modified().unwrap();
std::fs::write(&changed, picture(Png, [40, 40, 200])).unwrap();
// Link stamps use milliseconds: the new file is dated 2 s later.
let later = std::fs::FileTimes::new().set_modified(stamp + std::time::Duration::from_secs(2));
std::fs::OpenOptions::new().write(true).open(&changed).unwrap().set_times(later).unwrap();
// The folder moves: the linked files are found beside the document.
std::fs::rename(&before, &after).unwrap();
let path = file(&after, "poster.vectorcraft");
let bytes = std::fs::read(&path).unwrap();
let asked = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| (d.kind.clone(), d.fields.clone()));
for update in ["askWhenModified", "automatically"] {
app.session.prefs.update_links = update.into();
let r = crate::io::open_bytes(&mut app, &path, &bytes, Some(path.clone())).unwrap();
let rows = if update == "automatically" { &r["updatedLinks"] } else { &r["modifiedLinks"] };
assert_eq!(rows[0]["name"], "changed.png", "{update}: {r}");
let (at_once, question) = (app.session.doc().unwrap().doc.clone(), asked(&app));
assert!(question.as_ref().is_some_and(|(_, f)| f["missing"][0]["name"] == "gone.png"), "{update}: {question:?}");
app.ui.dialog = None;
start(&mut app, &path, bytes.clone()).unwrap();
let t0 = std::time::Instant::now();
while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 {
poll(&mut app);
std::thread::yield_now();
}
let st = app.session.doc().unwrap();
assert!(*st.doc == *at_once, "{update}: the same document");
assert!(st.history.undo.is_empty() && !st.is_dirty(), "{update}");
assert_eq!(asked(&app), question, "{update}");
}
let _ = std::fs::remove_dir_all(&dir);
}
}
+1
View File
@@ -519,6 +519,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
The Pathfinder panel, Offset Path, Clean Up, Divide Objects Below, Make and Release Blend, Envelope Distort's Make and Release, Repeat (Radial, Grid, Mirror and Release), Vector Halftone, New and Redefine Symbol, Delete Symbol, erasing with the Symbol Sprayer, Rasterize, Convert to Artboards, Flatten Transparency, Clear Guides, Shape Builder, Live Paint, Release Image Trace, Make and Release Opacity Mask, Delete with direct-selected anchor points, Remove Anchor Points, Cut Path, and the Shaper, Knife, Eraser, Path Eraser, Line Cut, Rectangle Cut, Mirror & Cut and Blob Brush tools add and remove objects in one pass over each layer or group they change, and Outline Stroke and Expand write each new object in the place of the object it replaces (#1429).
Delete, Cut, Group, Ungroup, Make and Release Compound Path, Make Clipping Mask, Send to Current Layer, Duplicate, Copy in the Move, Rotate, Scale, Reflect and Shear dialogs, Transform Each with Copy and each move of an Alt-drag take time in proportion to the size of the document (#1429).
An Alt-drag with the Selection, Direct Selection, Group Selection, Rotate, Scale, Reflect or Shear tool copies the selection on its first move, and the moves after it transform those copies; the previews of the Move, Rotate, Scale, Reflect and Shear dialogs with Copy and Preview on copy the selection once in the same way (#1429).
When the desktop app opens a document on a thread of its own (#1245), that thread also reads the document's linked files and lays out its type, and the UI thread only adds the finished document: in release builds, the step left on the UI thread takes 0.02 ms instead of 1.04 s for a document with 12 linked 3000 × 2000 TIFFs, and it takes 0.1 ms instead of 0.85 s for one with 300 Auto Size area type objects, every second one in three columns.
The Layers panel's Delete Selection, Duplicate Selection, Collect in New Layer, Merge Selected, Flatten Artwork, Release to Layers (Sequence) and row drags also take time in proportion to the size of the document, and so do Cut Artboard and Duplicate Artboard with their art and each move of an Alt-drag of one artboard with the Artboard tool while Move/Copy Artwork with Artboard is on (#1429).
In Outline view the renderer draws paths and compound paths from the geometry it keeps for them between frames, and it culls the members of clip groups by the bounds it keeps for them. Layers with Preview off and the outlines of Image Trace objects are drawn the same way.
- **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.