Links: a mask row's tooltip finds its object without walking the stored art

The panel collects the objects whose opacity masks hold a listed image in
the same pass over the stored art that collects the rows' objects, once per
list rebuild. Each frame then finds a row's mask object by the layer tree's
index or in that cache, instead of walking every stored root for objects
inside symbols, graph designs and patterns.
This commit is contained in:
dotCooCoo
2026-11-12 23:26:25 -08:00
parent 64241978ec
commit a367065afc
2 changed files with 57 additions and 16 deletions
+24 -16
View File
@@ -60,10 +60,18 @@ struct Cache {
at: f64,
list: Value,
info: Option<(u64, Value)>,
/// The objects of the rows outside the layer tree, by id, for their thumbnails.
/// The objects outside the layer tree, by id: those of the rows, for their thumbnails, and
/// those whose opacity masks hold a row's image, for the tooltip.
nested: Vec<(u64, std::sync::Arc<vectorcraft_doc::Node>)>,
}
impl Cache {
/// Object `id` outside the layer tree, as [`cached`] collected it.
fn nested_node(&self, id: u64) -> Option<&std::sync::Arc<vectorcraft_doc::Node>> {
self.nested.binary_search_by_key(&id, |(n, _)| *n).ok().and_then(|i| self.nested.get(i)).map(|(_, n)| n)
}
}
fn options(ctx: &egui::Context) -> Options {
pstate(ctx, "links-options")
}
@@ -86,9 +94,16 @@ fn cached(app: &mut VectorcraftApp, ctx: &egui::Context, o: &Options) -> Option<
if stale {
c.list = app.session.execute("links.list", &json!({ "show": o.show, "sort": o.sort, "dcsTransparency": o.dcs })).ok()?;
(c.key, c.at) = (key, now);
// One pass over the stored art for the rows outside the layer tree, not one per frame.
let mut want: std::collections::HashSet<u64> =
c.list["links"].as_array().into_iter().flatten().filter(|r| nested_row(r)).filter_map(|r| r["id"].as_u64()).collect();
// One pass over the stored art for the rows outside the layer tree and the objects whose
// opacity masks hold them, not one per frame.
let mut want: std::collections::HashSet<u64> = c.list["links"]
.as_array()
.into_iter()
.flatten()
.filter(|r| nested_row(r))
.flat_map(|r| [r["id"].as_u64(), r["owner"]["maskOf"].as_u64()])
.flatten()
.collect();
c.nested.clear();
if !want.is_empty()
&& let Some(st) = app.session.active()
@@ -477,16 +492,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if o.thumb > 0.0 {
let tr = egui::Rect::from_min_size(egui::pos2(x, rect.center().y - o.thumb / 2.0), vec2(o.thumb, o.thumb));
ui.painter().rect_filled(tr, 0.0, egui::Color32::WHITE);
let node = if nested_row(r) {
cache
.nested
.binary_search_by_key(&id, |(n, _)| *n)
.ok()
.and_then(|i| cache.nested.get(i))
.map(|(_, n)| std::sync::Arc::clone(n))
} else {
super::layers::node_arc(&doc, NodeId(id))
};
let node =
if nested_row(r) { cache.nested_node(id).map(std::sync::Arc::clone) } else { super::layers::node_arc(&doc, NodeId(id)) };
if let Some(n) = node {
super::layers::real_thumb(ui, &thumbs, &n, tr);
}
@@ -519,10 +526,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
egui::FontId::proportional(12.5),
t.text,
);
// The mask's object by the layer tree's index or from the cache: no walk of the
// stored art each frame.
let mask = r["owner"]["maskOf"]
.as_u64()
.and_then(|m| doc.stored_node(NodeId(m)))
.map(|(n, _)| n)
.and_then(|m| doc.node(NodeId(m)).or_else(|| cache.nested_node(m).map(|n| &**n)))
.map(|n| super::layers::shown_name(app, n, crate::i18n::current()));
let resp = resp.on_hover_text(status_tip(r, mask));
if resp.double_clicked() {
+33
View File
@@ -196,6 +196,39 @@ fn a_nested_rows_tooltip_names_what_holds_it() {
assert_eq!(super::links::status_tip(&row, None), "/x/a.png");
}
/// The tooltip of an image in the opacity mask of an object inside a symbol names that object,
/// which the panel finds among the objects it collected with the list.
#[test]
fn a_mask_rows_tooltip_names_its_object_inside_a_symbol() {
let dir = Folder::new("mask-owner");
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let rect = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap();
let mask = dir.file("mask.png", &png(20, 20));
let mask = app.run("file.place", json!({"path": mask, "at": [40, 40]})).unwrap()["ids"][0].as_u64().unwrap();
app.run("transparency.makeOpacityMask", json!({"ids": [rect, mask]})).unwrap();
app.run("symbol.new", json!({"name": "Mark", "ids": [rect]})).unwrap();
app.run("edit.clear", json!({"ids": selected(&app)})).unwrap();
let list = app.run("links.list", json!({})).unwrap();
let row = list["links"].as_array().unwrap().iter().find(|r| r["name"] == "mask.png").unwrap_or_else(|| panic!("{list}")).clone();
assert_eq!(row["owner"]["kind"], "symbol", "{list}");
let owner = vectorcraft_doc::NodeId(row["owner"]["maskOf"].as_u64().unwrap_or_else(|| panic!("{list}")));
let doc = app.session.active().unwrap().doc.clone();
assert!(doc.node(owner).is_none(), "the masked object is outside the layer tree");
let name = super::layers::shown_name(&app, doc.stored_node(owner).unwrap().0, crate::i18n::current());
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>, time: f64| {
super::tests_appearance::frame_raw(&ctx, app, egui::RawInput { events, time: Some(time), ..Default::default() }, super::links::show)
};
let at = super::tests_appearance::text_rect(&frame(&mut app, vec![], 1.0), "mask.png").center();
frame(&mut app, vec![egui::Event::PointerMoved(at)], 2.0);
frame(&mut app, vec![], 4.0);
let text: Vec<String> = frame(&mut app, vec![], 4.1).into_iter().map(|(t, _)| t).collect();
let want = format!("Opacity Mask: {name}");
assert!(text.iter().any(|t| t.contains(&want)), "{want} in {text:?}");
}
#[test]
fn an_empty_document_shows_how_to_add_images() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());