From 282a5176af663362ba6ef66c9da1b479356530ec Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 4 Aug 2026 16:40:22 +0300 Subject: [PATCH] fix(groups): highlight full group on hover Refs #454 --- src/scene/group_layer.rs | 45 +++++++++++++++++++++++++-------------- src/scene/mod.rs | 27 +++++++++++++++++------ src/scene/pipeline/mod.rs | 24 +++++++++++---------- src/scene/view/render.rs | 28 ++++++++++++------------ 4 files changed, 77 insertions(+), 47 deletions(-) diff --git a/src/scene/group_layer.rs b/src/scene/group_layer.rs index 6c007f8af..af957834e 100644 --- a/src/scene/group_layer.rs +++ b/src/scene/group_layer.rs @@ -175,23 +175,36 @@ impl Scene { count } - /// If `handle` belongs to any selectable groups, also select all other members of those groups. + /// Return `handles` plus every member of a selectable group containing one + /// of them. Selection and rollover highlighting share this expansion so the + /// preview matches what a click will select. + pub fn handles_expanded_for_selectable_groups( + &self, + handles: &[Handle], + ) -> HashSet { + let mut expanded: HashSet = handles.iter().copied().collect(); + expanded.extend( + self.document + .objects + .values() + .filter_map(|obj| match obj { + ObjectType::Group(g) + if g.selectable + && handles.iter().any(|handle| g.contains(*handle)) => + { + Some(g.entities.clone()) + } + _ => None, + }) + .flatten(), + ); + expanded + } + + /// If any handle belongs to a selectable group, also select every member. pub fn expand_selection_for_groups(&mut self, handles: &[Handle]) { - let to_add: Vec = self - .document - .objects - .values() - .filter_map(|obj| match obj { - ObjectType::Group(g) if g.selectable && handles.iter().any(|h| g.contains(*h)) => { - Some(g.entities.clone()) - } - _ => None, - }) - .flatten() - .collect(); - for h in to_add { - self.selected.insert(h); - } + self.selected + .extend(self.handles_expanded_for_selectable_groups(handles)); self.bump_selection(); } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 540f96569..5c7db96e1 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -4235,18 +4235,33 @@ impl Scene { if self.hover_highlight == handle { return; } - // Hover is folded into the highlight set (selected ∪ {hover}) that - // drives the xray overlay. A hover handle that's already selected - // contributes nothing, so the effective set is unchanged — skip the - // overlay refresh then. The field is still updated for hit-test / UI. - let contribution = |h: Option| h.filter(|h| !self.selected.contains(h)); - let changed = contribution(self.hover_highlight) != contribution(handle); + // Selectable groups roll over as one unit, matching click selection. + // Exclude already-selected members because they already contribute to + // the overlay and remain in the selected colour. + let contribution = |scene: &Scene, hovered: Option| { + hovered + .map(|handle| scene.handles_expanded_for_selectable_groups(&[handle])) + .unwrap_or_default() + .into_iter() + .filter(|handle| !scene.selected.contains(handle)) + .collect::>() + }; + let changed = contribution(self, self.hover_highlight) != contribution(self, handle); self.hover_highlight = handle; if changed { self.bump_selection(); } } + /// Handles that currently contribute the rollover colour. The stored hover + /// remains the picked member for hit-test/UI bookkeeping; rendering expands + /// it to the same selectable group that a click would select. + pub fn hover_highlight_handles(&self) -> HashSet { + self.hover_highlight + .map(|handle| self.handles_expanded_for_selectable_groups(&[handle])) + .unwrap_or_default() + } + /// Keep the current selection visible and temporarily filter every other /// top-level object. Block-definition children are intentionally not added /// here: an isolated INSERT remains a complete visible block instance. diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 838931c92..48fb66fe7 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -1793,13 +1793,12 @@ impl Pipeline { device: &wgpu::Device, wires: &[WireModel], selected: &rustc_hash::FxHashSet, - hover: Option, + hovered: &rustc_hash::FxHashSet, annotation_context_wires: &[WireModel], depth_map: &rustc_hash::FxHashMap, ) { let perf_started = crate::perf::enabled().then(iced::time::Instant::now); - let hover = hover.filter(|h| !selected.contains(h)); - if selected.is_empty() && hover.is_none() && annotation_context_wires.is_empty() { + if selected.is_empty() && hovered.is_empty() && annotation_context_wires.is_empty() { self.gpu_selected_wires = vec![]; return; } @@ -1818,7 +1817,7 @@ impl Pipeline { } } } - if let Some(h) = hover { + for h in hovered.iter().filter(|handle| !selected.contains(handle)) { if let Some(idxs) = self.wire_handle_index.get(&h.value()) { let mut slots = idxs.clone(); slots.sort_unstable(); @@ -1857,7 +1856,11 @@ impl Pipeline { crate::perf_record!( "[perf] wire-highlight {:>7.1}ms handles={} wires={}", elapsed_ms, - selected.len() + usize::from(hover.is_some()), + selected.len() + + hovered + .iter() + .filter(|handle| !selected.contains(handle)) + .count(), selected_wires.len() + hover_wires.len(), ); } @@ -1873,12 +1876,11 @@ impl Pipeline { device: &wgpu::Device, wires: &[WireModel], selected: &rustc_hash::FxHashSet, - hover: Option, + hovered: &rustc_hash::FxHashSet, annotation_context_wires: &[WireModel], ) { let perf_started = crate::perf::enabled().then(iced::time::Instant::now); - let hover = hover.filter(|h| !selected.contains(h)); - if selected.is_empty() && hover.is_none() && annotation_context_wires.is_empty() { + if selected.is_empty() && hovered.is_empty() && annotation_context_wires.is_empty() { self.text_highlight_vbuf = None; self.text_highlight_vcount = 0; return; @@ -1902,7 +1904,7 @@ impl Pipeline { for h in selected { push(h.value(), WireModel::SELECTED, wires, &mut out); } - if let Some(h) = hover { + for h in hovered.iter().filter(|handle| !selected.contains(handle)) { push(h.value(), WireModel::HOVER, wires, &mut out); } for wire in annotation_context_wires { @@ -2638,7 +2640,7 @@ impl Pipeline { pub fn update_mesh_highlight( &mut self, selected: &rustc_hash::FxHashSet, - hover: Option, + hovered: &rustc_hash::FxHashSet, ) { let mut out = Vec::new(); for handle in selected { @@ -2649,7 +2651,7 @@ impl Pipeline { })); } } - if let Some(handle) = hover.filter(|handle| !selected.contains(handle)) { + for handle in hovered.iter().filter(|handle| !selected.contains(handle)) { if let Some(ranges) = self.mesh_ranges_by_handle.get(&handle) { out.extend(ranges.iter().copied().map(|range| MeshHighlightDraw { range, diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 68dd01a6f..0ec42d94d 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -137,8 +137,8 @@ pub struct ViewportData { pub(in crate::scene) wire_patch: Option<(u64, Arc)>, /// Selected handles only (no hover) — solid meshes tint these blue. pub(in crate::scene) selected_handles: Arc>, - /// Currently hovered handle — solid meshes tint it orange. - pub(in crate::scene) hover_handle: Option, + /// Currently hovered selectable unit — solid meshes tint it orange. + pub(in crate::scene) hover_handles: Arc>, /// Bumped on selection / hover change. Paired with `wire_content_id` to /// decide when the xray overlay batch needs rebuilding. pub(in crate::scene) selection_generation: u64, @@ -789,12 +789,12 @@ impl shader::Primitive for Primitive { // wire buffers. let sel_key = (vp.wire_content_id, vp.selection_generation); let highlighted_geometry_unchanged = - vp.selected_handles.is_empty() && vp.hover_handle.is_none() + vp.selected_handles.is_empty() && vp.hover_handles.is_empty() || vp.wire_patch.as_ref().is_some_and(|(previous, patch)| { *previous == inner.cached_selection.0 && patch.changes.iter().all(|(handle, _)| { !vp.selected_handles.contains(handle) - && vp.hover_handle != Some(*handle) + && !vp.hover_handles.contains(handle) }) }); let selection_changed = inner.cached_selection.1 != vp.selection_generation; @@ -813,7 +813,7 @@ impl shader::Primitive for Primitive { device, &vp_wires[..], &vp.selected_handles, - vp.hover_handle, + &vp.hover_handles, &vp.annotation_context_wires, &draw_depths, ); @@ -824,7 +824,7 @@ impl shader::Primitive for Primitive { device, &vp_wires[..], &vp.selected_handles, - vp.hover_handle, + &vp.hover_handles, &vp.annotation_context_wires, ); inner.cached_annotation_highlight_source = @@ -864,7 +864,7 @@ impl shader::Primitive for Primitive { vp.selection_generation, ); if hl_key != inner.cached_highlight_key { - inner.update_mesh_highlight(&vp.selected_handles, vp.hover_handle); + inner.update_mesh_highlight(&vp.selected_handles, &vp.hover_handles); inner.cached_highlight_key = hl_key; } // Live overlay (command preview / interim / grip drag) — small and @@ -1978,12 +1978,12 @@ impl Scene { .copied() .map(|handle| (handle, true)) .collect(); - if let Some(handle) = self - .hover_highlight - .filter(|handle| !self.selected.contains(handle)) - { - highlighted.push((handle, false)); - } + highlighted.extend( + self.hover_highlight_handles() + .into_iter() + .filter(|handle| !self.selected.contains(handle)) + .map(|handle| (handle, false)), + ); highlighted.sort_unstable_by_key(|(handle, _)| handle.value()); let empty_selection = rustc_hash::FxHashSet::default(); @@ -2581,7 +2581,7 @@ impl Scene { wire_content_id, wire_patch, selected_handles: Arc::new(self.selected.iter().copied().collect()), - hover_handle: self.hover_highlight, + hover_handles: Arc::new(self.hover_highlight_handles()), selection_generation: self.selection_generation, selected_sig: self.selected_set_sig(), screen_rect,