diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 5c95e17bc7..2fb130ae3e 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -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)>, } +impl Cache { + /// Object `id` outside the layer tree, as [`cached`] collected it. + fn nested_node(&self, id: u64) -> Option<&std::sync::Arc> { + 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 = - 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 = 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() { diff --git a/crates/ui-egui/src/panels/tests_links.rs b/crates/ui-egui/src/panels/tests_links.rs index 89dfba5f43..8055c1288c 100644 --- a/crates/ui-egui/src/panels/tests_links.rs +++ b/crates/ui-egui/src/panels/tests_links.rs @@ -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, 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 = 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());