From b426859b1548499a54362a77bda7d7946785732b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 10 Oct 2026 06:49:58 +0700 Subject: [PATCH] Fix #833: isolation mode locks and dims the art around the group While a group or layer was isolated, clicks, hovers and drags still reached the art outside it, and that art drew as usual. Now only the isolated container's contents hit: the art around it is locked and clicks pass through it. On the canvas it draws at half opacity, above the group as well as below, each run of it as one dimmed layer. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/hit.rs | 25 ++++++++-- crates/render/src/lib.rs | 70 ++++++++++++++++++++++++++++ crates/render/src/tests_isolation.rs | 27 +++++++++++ crates/ui-egui/src/canvas.rs | 3 ++ crates/ui-egui/src/lib.rs | 2 + 5 files changed, 122 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index ac0546395..66c71c07c 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -86,6 +86,19 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option bool) -> Option { + let finish = |mut h: Hit| { + h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents)); + h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count(); + h + }; + // Isolation mode: only what is inside the isolated container hits. The art around it is + // locked, and clicks pass through it (#833). + if let Some(scope) = opt.scope + && let Some(container) = doc.node(scope).filter(|n| n.is_container()) + && let Some(mut chain) = doc.ancestry(scope) + { + return hit_children(container, p, opt, skip, &mut chain).map(finish); + } let mut chain = Vec::new(); for layer in doc.layers.iter().rev() { if !layer.visible || layer.locked { @@ -100,10 +113,8 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No } chain.push(layer.id); let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt }; - if let Some(mut h) = hit_children(layer, p, opt, skip, &mut chain) { - h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents)); - h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count(); - return Some(h); + if let Some(h) = hit_children(layer, p, opt, skip, &mut chain) { + return Some(finish(h)); } chain.pop(); } @@ -560,7 +571,11 @@ world", d.insert(Some(l), 9, far).unwrap(); let at = |x: f64, scope| objects_at(&d, Point::new(x, 25.0), HitOptions::default(), scope); assert_eq!(at(30.0, None), vec![g, a_id]); - assert_eq!(at(30.0, Some(g))[..2], [c_id, b_id], "isolated: the group's own objects"); + assert_eq!(at(30.0, Some(g)), [c_id, b_id], "isolated: the group's own objects"); + // Isolated, the art around the group is locked: clicks pass through it (#833). + assert!(at(5.0, Some(g)).is_empty()); + assert!(at(130.0, Some(g)).is_empty()); + assert!(hit_test(&d, Point::new(5.0, 25.0), HitOptions { scope: Some(g), ..HitOptions::default() }).is_none()); assert_eq!(at(5.0, None), vec![a_id]); assert!(at(90.0, None).is_empty()); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 386732e3c..301812861 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -107,8 +107,14 @@ pub struct RenderOptions { /// Images are sampled smoothly when scaled or rotated; off, each pixel takes its nearest image /// pixel (Pixel Preview with File Handling › Display Bitmaps as Anti-aliased Images off). pub smooth_images: bool, + /// Screen view in isolation mode: the isolated group or layer. Everything around it draws + /// dimmed ([`ISOLATION_DIM`]). + pub isolated: Option, } +/// The opacity of the art around an isolated group or layer. +pub const ISOLATION_DIM: f32 = 0.5; + /// How edges are rasterized (raster export option). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum AntiAlias { @@ -174,6 +180,7 @@ impl Default for RenderOptions { progressive_placed: false, trace_views: false, smooth_images: true, + isolated: None, } } } @@ -355,6 +362,11 @@ pub struct Renderer { /// Inline graphics being drawn inside inline graphics (a symbol whose art holds text showing /// it): drawing stops at [`MAX_INLINE_DEPTH`]. inline_depth: u32, + /// [`RenderOptions::isolated`]'s layers and groups, from the top layer down to it, for the + /// frame (`stamp`) and document they were found in. + isolation: Option<(u64, usize, NodeId, Vec)>, + /// Drawing inside the isolated container or a dimmed object: nothing further dims. + isolation_settled: bool, } /// How deep inline graphics nest (text in a symbol shown inline in text…) before they draw nothing. @@ -441,6 +453,8 @@ impl Renderer { adjusted: Default::default(), dim_images: None, inline_depth: 0, + isolation: None, + isolation_settled: false, } } @@ -876,6 +890,46 @@ impl Renderer { /// [`Self::draw_node`] after culling and Layer Options. fn draw_layer_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node) { + // Isolation mode: the art around the isolated container draws dimmed (#833). + if let Some((isolated, path)) = self.isolation_path(f) { + // A container on the way down to it draws as usual: its members decide. + if path.contains(&n.id) && n.id != isolated { + return self.draw_layer_node_now(ctx, f, n); + } + self.isolation_settled = true; + if n.id == isolated { + self.draw_layer_node_now(ctx, f, n); + } else { + self.dimmed(ctx, f, &mut |r, c, fr| r.draw_layer_node_now(c, fr, n)); + } + self.isolation_settled = false; + return; + } + self.draw_layer_node_now(ctx, f, n); + } + + /// In isolation mode, while drawing outside the isolated container and the art dimmed around + /// it: the isolated container and its layers and groups, from the top layer down to it. + fn isolation_path(&mut self, f: &Frame) -> Option<(NodeId, Vec)> { + let isolated = f.opts.isolated.filter(|_| !self.isolation_settled)?; + let doc = std::ptr::from_ref(f.doc) as usize; + if !self.isolation.as_ref().is_some_and(|(stamp, d, id, _)| (*stamp, *d, *id) == (self.stamp, doc, isolated)) { + self.isolation = Some((self.stamp, doc, isolated, f.doc.ancestry(isolated).unwrap_or_default())); + } + let path = self.isolation.as_ref().map(|(.., path)| path.clone()).filter(|p| !p.is_empty())?; + Some((isolated, path)) + } + + /// `draw` at [`ISOLATION_DIM`], with nothing inside dimmed again. + fn dimmed(&mut self, ctx: &mut RenderContext, f: &Frame, draw: &mut group::Content) { + let settled = std::mem::replace(&mut self.isolation_settled, true); + let comp = Composite { opacity: ISOLATION_DIM, ..Default::default() }; + self.group(ctx, f, comp, draw); + self.isolation_settled = settled; + } + + /// [`Self::draw_layer_node`] past isolation mode's dimming. + fn draw_layer_node_now(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node) { if fx::has_object_fx(n) { return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false); } @@ -998,6 +1052,22 @@ impl Renderer { fn draw_children(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc], knockout: bool) { if knockout { self.draw_knockout(ctx, f, children); + } else if let Some((_, path)) = self.isolation_path(f) { + // Isolation mode, in a container on the way down to the isolated one: each run of + // members around it draws dimmed as one (#833). + let mut rest = children; + while let Some(i) = rest.iter().position(|c| path.contains(&c.id)) { + let (around, from) = rest.split_at(i); + if !around.is_empty() { + self.dimmed(ctx, f, &mut |r, c, fr| around.iter().for_each(|n| r.draw_arc(c, fr, n))); + } + let Some((on_path, after)) = from.split_first() else { break }; + self.draw_arc(ctx, f, on_path); + rest = after; + } + if !rest.is_empty() { + self.dimmed(ctx, f, &mut |r, c, fr| rest.iter().for_each(|n| r.draw_arc(c, fr, n))); + } } else { for c in children { self.draw_arc(ctx, f, c); diff --git a/crates/render/src/tests_isolation.rs b/crates/render/src/tests_isolation.rs index 1db73de1d..138bb14b0 100644 --- a/crates/render/src/tests_isolation.rs +++ b/crates/render/src/tests_isolation.rs @@ -218,3 +218,30 @@ fn copying_the_backdrop_changes_nothing_without_blending() { let worst = a.iter().zip(&b).map(|(x, y)| (*x as i32 - *y as i32).abs()).max().unwrap(); assert!(worst <= 1, "differs by {worst}/255"); } + +/// Isolation mode (#833): the art around the isolated group draws at half opacity, above it as +/// well as below; the group's own art as usual. +#[test] +fn isolation_mode_dims_the_art_around_the_isolated_group() { + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let below = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 40.0), [0.0, 0.0, 0.0]); + let inside = rect(&mut d, Rect::new(60.0, 60.0, 100.0, 100.0), [0.0, 0.0, 0.0]); + let above = rect(&mut d, Rect::new(60.0, 0.0, 100.0, 40.0), [0.0, 0.0, 0.0]); + let g = group(&mut d, vec![inside], 1.0, Blend::Normal, false); + let gid = g.id; + for n in [below, g, above] { + d.insert(Some(l), usize::MAX, n).unwrap(); + } + let alpha = |img: &Rendered, x: usize, y: usize| img.pixels[(y * 100 + x) * 4 + 3]; + let plain = render(&d); + let isolated = |id| render_with(&mut Renderer::new(), &d, &RenderOptions { isolated: Some(id), ..Default::default() }); + let dimmed = isolated(gid); + assert_eq!(alpha(&dimmed, 80, 80), 255, "the isolated group's art"); + for (x, y) in [(20, 20), (80, 20)] { + assert_eq!(alpha(&plain, x, y), 255); + assert!((120..=135).contains(&alpha(&dimmed, x, y)), "({x}, {y}): {}", alpha(&dimmed, x, y)); + } + // An object that isn't in the document dims nothing. + assert_eq!(isolated(NodeId(u64::MAX)).pixels, plain.pixels); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2c5085533..04392757f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -249,6 +249,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { placed: vectorcraft_render::placed_document::generation(), pixel, smooth_images, + isolated: st.isolation.map(|id| id.0), }; // Placed documents' bitmaps are being made: draw again when they are ready. if vectorcraft_render::placed_document::busy() { @@ -298,6 +299,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { progressive_placed: true, trace_views: true, smooth_images, + // Isolation mode: the art around the isolated group or layer is dimmed (#833). + isolated: key.isolated.map(vectorcraft_doc::NodeId), ..opts }; // Light documents render synchronously (no lag vs overlays); heavy ones go to the worker. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 89086af33..6c3ccdff2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -293,6 +293,8 @@ pub struct CacheKey { pub pixel: Option<[i64; 4]>, /// Images sampled smoothly ([`vectorcraft_render::RenderOptions::smooth_images`]). pub smooth_images: bool, + /// Isolation mode's group or layer ([`vectorcraft_render::RenderOptions::isolated`]). + pub isolated: Option, } #[derive(Clone, Copy, Debug, Default)]