diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 8f8778689..2a737d2fb 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -69,7 +69,7 @@ use model::{push_rows, rows_of_doc}; use rows::{drag_ghost, key, rename_id, resolve_click, row}; #[cfg(test)] use thumbs::THUMBS; -pub(crate) use thumbs::{ThumbSource, node_arc, real_thumb, thumb_options}; +pub(crate) use thumbs::{ThumbSource, node_arc, real_thumb, show_thumbs_of, thumb_options}; /// Width of the eye and the lock columns. const COLUMN: f32 = 25.0; @@ -184,6 +184,7 @@ pub(crate) const ROW_KEYS: &str = "ly-keys"; /// The Layers panel: the layer tree with its click, drag and rename rules and the bottom buttons; Delete/Backspace after a row click delete the highlighted rows. pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + show_thumbs_of(app.session.active().map(|st| st.uid)); let Some(st) = app.session.active() else { return }; // A rename belongs to the document it started in (node ids are per document): drop it when // another document shows, so it can't rename that document's node with the same id. diff --git a/crates/ui-egui/src/panels/layers/tests.rs b/crates/ui-egui/src/panels/layers/tests.rs index 297afd693..6f65e5d8c 100644 --- a/crates/ui-egui/src/panels/layers/tests.rs +++ b/crates/ui-egui/src/panels/layers/tests.rs @@ -239,6 +239,42 @@ fn row_thumbnails_wait_for_the_end_of_a_drag() { assert_eq!(renders(), after, "once"); } +#[test] +fn the_row_thumbnails_follow_the_document_shown_and_none_once_it_closes() { + let (mut app, _, _) = two_rects(); + let ctx = egui::Context::default(); + let showing = || THUMBS.with(|c| c.borrow().showing); + frame(&mut app, &ctx, vec![], false); + let first = app.session.active().map(|st| st.uid); + assert_eq!(showing(), first); + // With no thumbnails drawn, the panel still tells which document it shows. + let p = &mut app.ui.layers_panel; + (p.thumb_layers, p.thumb_groups, p.thumb_objects) = (false, false, false); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + frame(&mut app, &ctx, vec![], false); + let second = app.session.active().map(|st| st.uid); + assert_ne!(second, first); + assert_eq!(showing(), second, "the new document's rows are drawn"); + for _ in 0..2 { + app.session.execute("file.close", &json!({"index": 0})).unwrap(); + } + frame(&mut app, &ctx, vec![], false); + assert_eq!(showing(), None, "no document is shown once both close"); + + // The Links panel alone does the same. + let links = |app: &mut VectorcraftApp| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::links::show(app, ui)); + out.textures_delta.clear(); + }; + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + links(&mut app); + assert_eq!(showing(), app.session.active().map(|st| st.uid)); + app.session.execute("file.close", &json!({"index": 0})).unwrap(); + links(&mut app); + assert_eq!(showing(), None, "the Links panel shows no document"); +} + #[test] fn clicking_a_target_circle_targets_the_layer() { let (mut app, layer, [a, b]) = two_rects(); diff --git a/crates/ui-egui/src/panels/layers/thumbs.rs b/crates/ui-egui/src/panels/layers/thumbs.rs index db303a11c..78e1217c2 100644 --- a/crates/ui-egui/src/panels/layers/thumbs.rs +++ b/crates/ui-egui/src/panels/layers/thumbs.rs @@ -55,6 +55,8 @@ pub(super) struct Thumbs { pending: HashMap<(u64, NodeId), (Weak, u32)>, worker: Option>, worker_started: bool, + /// The uid of the document whose rows were last drawn: the worker holds jobs for it alone. + pub(super) showing: Option, settle: Settle, /// (pass, ms, count) rendered on the UI thread in that pass. spent: (u64, f64, u32), @@ -275,6 +277,24 @@ impl Thumbs { } } + /// The panels draw the rows of document `uid` (None: no document). When that is another + /// document than before, the worker drops the jobs it has not started for every other + /// document, so they don't hold up this one's thumbnails, and those rows are asked for again + /// when their document shows again. The thumbnails made already stay. + pub(super) fn show(&mut self, uid: Option) { + if self.showing == uid { + return; + } + self.showing = uid; + self.pending.retain(|key, _| Some(key.0) == uid); + if let Some(w) = &self.worker + && !w.discard(move |key| Some(key.0) != uid) + { + self.worker = None; + self.pending.clear(); + } + } + /// Keep the thumbnail `d` the worker finished. fn receive(&mut self, ctx: &egui::Context, d: Done) { let (key, node, px) = d.key; @@ -305,6 +325,7 @@ impl Thumbs { for d in done { self.receive(ctx, d); } + self.show(Some(src.uid)); let key = (src.uid, n.id); let pass = ctx.cumulative_pass_nr(); let (fresh, light) = match self.entries.get_mut(&key) { @@ -356,6 +377,12 @@ impl Thumbs { } } +/// The Layers or Links panel shows the rows of document `uid` in this frame (None: no document +/// is open), whether or not a thumbnail is drawn; see [`Thumbs::show`]. +pub(crate) fn show_thumbs_of(uid: Option) { + THUMBS.with(|c| c.borrow_mut().show(uid)); +} + /// A real rendered thumbnail of row `n`, cached by node identity (unchanged nodes keep their `Arc` /// allocation, so the address is a free change detector). Only rendered for visible rows; see /// [`Thumbs::texture`] for when. @@ -729,6 +756,125 @@ mod tests { assert!(thumbs.pending.is_empty()); } + /// A document of `rows` paths of 5,000 anchors on its layer, and the paths as the layer holds + /// them. + fn long_paths(rows: usize) -> (Arc, Vec>) { + let mut doc = Document::new(200.0, 200.0); + let layer = doc.layers.first().map(|l| l.id); + let ids: Vec = (0..rows) + .map(|_| { + let id = doc.alloc_id(); + doc.insert(layer, usize::MAX, path(id.0, 5_000)).unwrap(); + id + }) + .collect(); + let doc = Arc::new(doc); + let nodes = ids.iter().filter_map(|id| node_arc(&doc, *id)).collect(); + (doc, nodes) + } + + /// Keep the worker of `thumbs` busy with a job of row (0, 0) until the sender returned sends, + /// so the jobs submitted meanwhile wait in its queue. + fn hold(thumbs: &Thumbs) -> std::sync::mpsc::Sender<()> { + let (started, running) = std::sync::mpsc::channel::<()>(); + let (release, held) = std::sync::mpsc::channel::<()>(); + let key = (0, NodeId(0)); + let job: crate::thumb_worker::JobFn = Box::new(move |_| { + started.send(()).ok(); + held.recv().ok(); + None + }); + assert!(thumbs.worker.as_ref().is_some_and(|w| w.submit(key, (key, Weak::new(), 1), job))); + running.recv().expect("the job starts"); + release + } + + /// The next job `thumbs`'s worker finishes, checked against the thumbnail of the same node in + /// `rows` of `doc` made on this thread, then kept. + fn receive_checked(ctx: &egui::Context, thumbs: &mut Thumbs, uid: u64, doc: &Document, rows: &[Arc]) { + let done = thumbs.worker.as_ref().and_then(ThumbWorker::wait).expect("the worker finishes a job"); + assert_eq!(done.key.0.0, uid, "a job of document {uid} is done next"); + let n = rows.iter().find(|n| n.id == done.key.0.1).expect("one of its rows"); + let there = done.img.as_ref().expect("the worker draws the path"); + let here = render_thumb(&mut vectorcraft_render::Renderer::new(), doc, n, 24).expect("this thread draws the path"); + assert!(here.pixels.chunks(4).any(|p| p[3] > 0), "the thumbnail shows the path"); + assert_eq!((there.width, there.height, &there.pixels), (here.width, here.height, &here.pixels), "the same pixels"); + thumbs.receive(ctx, done); + } + + #[test] + fn the_shown_documents_thumbnails_are_made_before_the_queued_ones_of_another() { + let ctx = egui::Context::default(); + let Some(worker) = ThumbWorker::start(&ctx, "test") else { return }; + let mut thumbs = Thumbs { worker: Some(worker), worker_started: true, ..Default::default() }; + let (a, a_rows) = long_paths(3); + let (b, b_rows) = long_paths(2); + let release = hold(&thumbs); + let src_a = ThumbSource { uid: 1, revision: 1, doc: &a, busy: false }; + for n in &a_rows { + assert!(thumbs.texture(&ctx, &src_a, n, 24).is_none()); + } + assert_eq!(thumbs.pending.len(), 3, "document 1's rows wait on the worker"); + // Document 2 shows before the worker gets to them. + let src_b = ThumbSource { uid: 2, revision: 1, doc: &b, busy: false }; + for n in &b_rows { + assert!(thumbs.texture(&ctx, &src_b, n, 24).is_none()); + } + assert_eq!(thumbs.pending.keys().map(|k| k.0).collect::>(), [2, 2], "only the shown document's rows are pending"); + release.send(()).expect("the held job is waiting"); + let held = thumbs.worker.as_ref().and_then(ThumbWorker::wait).expect("the held job finishes"); + assert_eq!(held.key.0, (0, NodeId(0))); + for _ in &b_rows { + receive_checked(&ctx, &mut thumbs, 2, &b, &b_rows); + } + for n in &b_rows { + assert!(thumbs.texture(&ctx, &src_b, n, 24).is_some(), "the shown document's thumbnails are shown"); + } + // Shown again, document 1's rows are asked for again and come back. + for n in &a_rows { + assert!(thumbs.texture(&ctx, &src_a, n, 24).is_none()); + } + assert_eq!(thumbs.pending.keys().filter(|k| k.0 == 1).count(), 3); + for _ in &a_rows { + receive_checked(&ctx, &mut thumbs, 1, &a, &a_rows); + } + for n in &a_rows { + assert!(thumbs.texture(&ctx, &src_a, n, 24).is_some()); + } + // Document 2's thumbnails were kept: showing it again asks for nothing. + for n in &b_rows { + assert!(thumbs.texture(&ctx, &src_b, n, 24).is_some()); + } + assert!(thumbs.pending.is_empty()); + assert_eq!(thumbs.renders, 0, "none was made on this thread"); + } + + #[test] + fn the_queued_thumbnails_of_a_closed_document_are_dropped() { + let ctx = egui::Context::default(); + let Some(worker) = ThumbWorker::start(&ctx, "test") else { return }; + let mut thumbs = Thumbs { worker: Some(worker), worker_started: true, ..Default::default() }; + let (a, a_rows) = long_paths(3); + let release = hold(&thumbs); + let src_a = ThumbSource { uid: 1, revision: 1, doc: &a, busy: false }; + for n in &a_rows { + thumbs.texture(&ctx, &src_a, n, 24); + } + // The document closes and no other shows. + thumbs.show(None); + assert!(thumbs.pending.is_empty()); + // A job queued after it, straight on the worker, comes back next. + let (b, b_rows) = long_paths(1); + let (doc, node) = (b.clone(), b_rows[0].clone()); + let key = (2, node.id); + let job: crate::thumb_worker::JobFn = Box::new(move |r| render_thumb(r, &doc, &node, 24)); + assert!(thumbs.worker.as_ref().is_some_and(|w| w.submit(key, (key, Arc::downgrade(&b_rows[0]), 24), job))); + release.send(()).expect("the held job is waiting"); + let held = thumbs.worker.as_ref().and_then(ThumbWorker::wait).expect("the held job finishes"); + assert_eq!(held.key.0, (0, NodeId(0))); + receive_checked(&ctx, &mut thumbs, 2, &b, &b_rows); + } + #[test] fn an_instance_of_a_long_symbols_thumbnail_is_made_on_the_worker_and_matches_one_made_here() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 9fc91f506..d924d06f3 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -316,6 +316,7 @@ fn click(app: &mut VectorcraftApp, ctx: &egui::Context, rows: &[Value], id: u64, pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let o = options(ui.ctx()); + super::layers::show_thumbs_of(app.session.active().map(|st| st.uid)); let Some(cache) = cached(app, ui.ctx(), &o) else { widgets::dim_label(ui, tl!("No document")); return; diff --git a/crates/ui-egui/src/thumb_worker.rs b/crates/ui-egui/src/thumb_worker.rs index ceea0b3ce..c754cd786 100644 --- a/crates/ui-egui/src/thumb_worker.rs +++ b/crates/ui-egui/src/thumb_worker.rs @@ -32,11 +32,20 @@ struct Job { run: JobFn, } +/// Which slots' jobs [`ThumbWorker::discard`] drops. +type SlotTest = Box bool + Send>; + +/// What a panel sends its worker: a job, or the slots whose jobs not started yet are dropped. +enum Msg { + Job(Job), + Discard(SlotTest), +} + /// A render thread for one panel's thumbnails. Jobs go in by slot (the Navigator has one, the /// Layers panel one per row): a job not started yet is replaced by a newer one for its slot, so /// only the latest image of anything is drawn. pub struct ThumbWorker { - tx: Sender>, + tx: Sender>, rx: Receiver>, } @@ -53,7 +62,7 @@ impl ThumbWorker { /// Spawn the thread. pub(crate) fn start(ctx: &egui::Context, name: &str) -> Option { - let (tx, jobs) = channel::>(); + let (tx, jobs) = channel::>(); let (done_tx, rx) = channel::>(); let ctx = ctx.clone(); std::thread::Builder::new() @@ -61,9 +70,12 @@ impl ThumbWorker { .spawn(move || { let mut r = Renderer::new(); let mut queue: VecDeque> = VecDeque::new(); - let add = |queue: &mut VecDeque>, job: Job| match queue.iter_mut().find(|j| j.slot == job.slot) { - Some(old) => *old = job, - None => queue.push_back(job), + let add = |queue: &mut VecDeque>, msg: Msg| match msg { + Msg::Job(job) => match queue.iter_mut().find(|j| j.slot == job.slot) { + Some(old) => *old = job, + None => queue.push_back(job), + }, + Msg::Discard(which) => queue.retain(|j| !which(&j.slot)), }; loop { if queue.is_empty() { @@ -91,7 +103,15 @@ impl ThumbWorker { /// thread is gone: the caller renders on the UI thread from now on. #[must_use] pub fn submit(&self, slot: S, key: K, run: JobFn) -> bool { - self.tx.send(Job { slot, key, run }).is_ok() + self.tx.send(Msg::Job(Job { slot, key, run })).is_ok() + } + + /// Drop the jobs not started yet whose slot `which` picks, so they don't hold up the jobs + /// submitted after them; they never come back from [`ThumbWorker::poll`]. A job already + /// running still finishes. False when the thread is gone, as for [`ThumbWorker::submit`]. + #[must_use] + pub fn discard(&self, which: impl Fn(&S) -> bool + Send + 'static) -> bool { + self.tx.send(Msg::Discard(Box::new(which))).is_ok() } /// The jobs finished since the last call, oldest first; None when the thread is gone (the @@ -169,6 +189,31 @@ mod tests { drop(w); } + #[test] + fn discarded_jobs_do_not_run_and_the_running_one_finishes() { + let Some(w) = ThumbWorker::::start(&egui::Context::default(), "test") else { return }; + let job = |n: u32| -> JobFn { Box::new(move |_| Some(Rendered { width: n, height: 1, pixels: vec![0; 4] })) }; + // A job that holds the thread until it is let go, so the others queue behind it. + let (started, running) = channel::<()>(); + let (release, held) = channel::<()>(); + let blocker: JobFn = Box::new(move |_| { + started.send(()).ok(); + held.recv().ok(); + None + }); + assert!(w.submit(0, 0, blocker)); + running.recv().expect("the first job starts"); + assert!(w.submit(1, 1, job(1))); + assert!(w.submit(2, 2, job(2))); + assert!(w.submit(1, 3, job(3))); + assert!(w.discard(|slot| *slot == 0 || *slot == 1)); + assert!(w.submit(3, 4, job(4))); + release.send(()).expect("the first job is waiting"); + assert_eq!(w.wait().map(|d| d.key), Some(0), "the running job finishes"); + assert_eq!(w.wait().map(|d| d.key), Some(2), "slot 1's jobs were dropped"); + assert_eq!(w.wait().map(|d| d.key), Some(4), "a job submitted after the discard runs"); + } + #[test] fn settles_after_the_document_stays_unchanged() { let ctx = egui::Context::default(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 50e1b0cf2..004188166 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1289,6 +1289,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In the web build the status bar's render and UI times are measured with the browser's `performance.now()`, and the Layers panel stops rendering row thumbnails in a frame once they have taken 8 ms. On the desktop, the Layers panel renders a row's thumbnail on its thumbnail thread when the row's paths, and the paths of its opacity masks that are not disabled, hold more than 2,000 anchors in all. It already did this for the first thumbnail of a row of more than 200 objects. Until its first thumbnail is ready, the row shows boxes at the bounds of up to 41 of its paths, each in its path's solid fill or stroke color or in gray, when its own paths, without its opacity masks, hold at most 2,000 anchors in all, and an empty white box otherwise. With one path of 200,000 anchors, the first frame after the panel opens takes 2.4 ms instead of 393 ms (release build, median of three runs). The same 2,000-anchor limit counts each glyph of type as 4 anchors, each point of a gradient mesh as 1 and every 100 pixels of an image as 1, and a symbol instance or a placed document counts the objects and anchors of the art it shows. A placed document whose file has not been read yet, or a symbol nested more than 8 deep (one that holds an instance of itself, for example), gets its first thumbnail on the thumbnail thread, and so does a row whose art takes more than 20,000 steps to count (each object, subpath, text run and symbol looked at is one step). The first frame after the panel opens takes 2.4 ms instead of 19.8 ms with 5,000 glyphs of point type, 1.8 ms instead of 343 ms with one instance of a symbol of a 200,000-anchor path, 2.0 ms instead of 11.0 ms with a 100 × 100 mesh, and 2.1 ms instead of 274 ms with a placed document of a 200,000-anchor path (release build, median of three runs). + When the Layers or Links panel shows another document's rows, the thumbnail thread drops the row thumbnails it has not started for the documents not shown, and it drops all of them when the Layers or Links panel is open after the last document closes. Those rows ask for their thumbnails again when their document shows again, and the thumbnails already made are kept. With 150 rows of 20,000-anchor paths waiting for another document, the 8 rows of such paths of the document shown get their thumbnails in 153 ms instead of 3,265 ms (debug test build, median of three runs). In the web build, while a pan, zoom or rotation changes the view of a document whose last canvas render took more than 8 ms, the canvas draws the last frame moved with the view, and it renders the art again once the view has been still for 0.2 s. 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). An Alt-drag of objects in perspective with the Perspective Selection tool, and an Alt-drag of a plane widget with that tool or the Perspective Grid tool, copy the objects on the first move, and the moves after it move those copies (#1429).