diff --git a/src/app/mod.rs b/src/app/mod.rs index 69e09fc03..dddbad85e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -639,6 +639,9 @@ pub(super) struct OpenCADStudio { /// `HOVER_DWELL_MS`. Skipping the pick mid-stroke avoids the per-frame /// O(N) wire+hatch+mesh sweep that froze the cursor on large drawings. hover_dwell: Option, + /// Constraint kind shown after the ordinary rollover dwell while the + /// cursor remains over one of its viewport indicators. + constraint_glyph_tooltip: Option, /// Snapshots of edited entities taken at the start of a grip drag. The drag /// mutates the document live, so Escape restores this group atomically. grip_originals: Vec<(acadrust::Handle, acadrust::EntityType)>, @@ -3689,6 +3692,7 @@ impl OpenCADStudio { grip_add_provisional: None, grip_preview_handles: Vec::new(), hover_dwell: None, + constraint_glyph_tooltip: None, grip_originals: Vec::new(), grip_history_originals: Vec::new(), grip_dirty_before: None, diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 958df1fd6..a727351ca 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -933,9 +933,10 @@ impl OpenCADStudio { return Task::none(); } let i = self.active_tab; + self.constraint_glyph_tooltip = None; let constraint_hover = self .constraint_glyph_under(i, p) - .map(|(_, handles)| handles) + .map(|(_, references)| references) .unwrap_or_default(); self.tabs[i] .scene @@ -1766,7 +1767,7 @@ impl OpenCADStudio { .solve_parametric_constraints_preview( &edited_handles, &driven_refs, - self.constraint_solve_mode && !driven_refs.is_empty(), + false, &self.grip_originals, ); for (handle, _) in &solved_by_constraints { @@ -2983,7 +2984,7 @@ impl OpenCADStudio { cursor: iced::Point, ) -> Option<( crate::scene::parametric_constraints::ConstraintKind, - Vec, + Vec, )> { if self.tabs[i].scene.current_layout != "Model" { return None; @@ -2999,10 +3000,7 @@ impl OpenCADStudio { )?; let set = self.tabs[i].scene.parametric_constraint_set(scope)?; let constraint = set.get(id)?; - let mut handles: Vec<_> = constraint.refs.iter().map(|r| r.entity).collect(); - handles.sort_unstable_by_key(|handle| handle.value()); - handles.dedup(); - Some((constraint.kind, handles)) + Some((constraint.kind, constraint.refs.clone())) } pub(super) fn on_viewport_left_press(&mut self) -> Task { @@ -3061,7 +3059,10 @@ impl OpenCADStudio { let (vw, vh) = vp_size; if self.tabs[i].active_cmd.is_none() { - if let Some((kind, handles)) = self.constraint_glyph_under(i, p) { + if let Some((kind, references)) = self.constraint_glyph_under(i, p) { + let mut handles: Vec<_> = references.iter().map(|reference| reference.entity).collect(); + handles.sort_unstable_by_key(|handle| handle.value()); + handles.dedup(); self.tabs[i].scene.deselect_all(); self.tabs[i].scene.select_entities(&handles); self.refresh_selected_grips(); @@ -5179,6 +5180,7 @@ properties={:.1}ms picked={}", } self.tabs[i].scene.set_hover_highlight(None); self.hover_dwell = None; + self.constraint_glyph_tooltip = None; self.grip_hover = None; self.ucs_icon_hover = false; } @@ -5500,6 +5502,10 @@ properties={:.1}ms picked={}", self.clear_navigation_hover(i); return Task::none(); } + let cursor = self.tabs[i].scene.selection.borrow().last_move_pos; + self.constraint_glyph_tooltip = cursor.and_then(|point| { + self.constraint_glyph_under(i, point).map(|(kind, _)| kind) + }); let bounds = iced::Rectangle { x: 0.0, y: 0.0, @@ -6135,6 +6141,12 @@ mod selection_preview_tests { current[1], before[1], "constrained neighbor must follow each grip frame" ); + let start_delta = current[0][0] - before[0][0]; + let end_delta = current[0][1] - before[0][1]; + assert!( + (start_delta + end_delta).length_squared() > 1.0e-8, + "the opposite endpoint must not mirror the dragged endpoint around a fixed midpoint" + ); } let _ = app.on_viewport_left_release(); assert!(app.tabs[i].active_grip.is_none()); @@ -6145,6 +6157,106 @@ mod selection_preview_tests { assert_eq!(geometry(&app), after); } + #[test] + fn constrained_polyline_vertex_grip_reshapes_instead_of_translating() { + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope}; + use acadrust::{entities::LwPolyline, types::Vector2, EntityType}; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.snapper.snap_enabled = false; + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + let mut rectangle = LwPolyline::from_points(vec![ + Vector2::new(0.0, 0.0), + Vector2::new(4.0, 0.0), + Vector2::new(4.0, 2.0), + Vector2::new(0.0, 2.0), + ]); + rectangle.is_closed = true; + let handle = app.tabs[i].scene.add_entity(EntityType::LwPolyline(rectangle)); + let set = app.tabs[i].scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add(ConstraintKind::Parallel, vec![ParametricRef::segment(handle, 0), ParametricRef::segment(handle, 2)], None); + set.add(ConstraintKind::Parallel, vec![ParametricRef::segment(handle, 1), ParametricRef::segment(handle, 3)], None); + set.add(ConstraintKind::Perpendicular, vec![ParametricRef::segment(handle, 3), ParametricRef::segment(handle, 2)], None); + set.add(ConstraintKind::Horizontal, vec![ParametricRef::segment(handle, 2)], None); + let vertices = |app: &OpenCADStudio| { + let EntityType::LwPolyline(polyline) = + app.tabs[i].scene.document.get_entity(handle).unwrap() + else { + panic!("expected polyline") + }; + polyline.vertices.iter().map(|vertex| vertex.location).collect::>() + }; + let before = vertices(&app); + app.tabs[i].active_grip = Some(GripEdit::single(handle, 0, false, glam::DVec3::ZERO)); + + let _ = app.on_viewport_move(Point::new(460.0, 220.0)); + + let after = vertices(&app); + let dragged = after[0] - before[0]; + assert!(dragged.length_squared() > 1.0e-12); + assert!(after.iter().zip(&before).skip(1).any(|(after, before)| + (*after - *before - dragged).length_squared() > 1.0e-12)); + let segments = [ + after[1] - after[0], + after[2] - after[1], + after[3] - after[2], + after[0] - after[3], + ]; + let cross = |a: Vector2, b: Vector2| a.x * b.y - a.y * b.x; + let dot = |a: Vector2, b: Vector2| a.x * b.x + a.y * b.y; + assert!(cross(segments[0], segments[2]).abs() < 1.0e-6); + assert!(cross(segments[1], segments[3]).abs() < 1.0e-6); + assert!(dot(segments[3], segments[2]).abs() < 1.0e-6); + assert!(segments[2].y.abs() < 1.0e-6); + } + + #[test] + fn constraint_glyph_tooltip_appears_after_hover_dwell() { + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope}; + use acadrust::{entities::Line, types::Vector3, EntityType}; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + let handle = app.tabs[i].scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(-1.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ))); + app.tabs[i].scene.parametric_constraint_set_mut(ParametricScope::ModelSpace).add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(handle)], + None, + ); + let anchor = app.tabs[i].scene.constraint_glyph_placements_screen( + ParametricScope::ModelSpace, + (800.0, 600.0), + true, + 3, + )[0].1; + let point = (-30..=30).find_map(|y| { + (-30..=30).find_map(|x| { + let point = Point::new(anchor.x + x as f32, anchor.y + y as f32); + app.constraint_glyph_under(i, point).is_some().then_some(point) + }) + }).expect("projected constraint glyph should be hit-testable"); + + let _ = app.on_viewport_move(point); + assert_eq!(app.constraint_glyph_tooltip, None); + app.hover_dwell.as_mut().unwrap().last_move_at = + Instant::now() + - std::time::Duration::from_millis(crate::app::HOVER_DWELL_MS as u64 + 1); + let _ = app.on_hover_dwell_tick(); + + assert_eq!(app.constraint_glyph_tooltip, Some(ConstraintKind::Horizontal)); + } + #[cfg(target_os = "linux")] #[test] fn hyperlink_click_respects_existing_pointer_owners() { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 0e3c7fdae..6ba89cd45 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -888,6 +888,8 @@ bg={bg_ms:.1}ms n={view_count}" grip_hover: self.model_space.grip_hover, }, constraint_glyphs, + self.constraint_glyph_tooltip + .map(|kind| crate::t!(kind.label()).into_owned()), ) }; diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 59eb787f3..0f404a58d 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1683,6 +1683,10 @@ pub struct Scene { pub hover_highlight: Option, /// All entities related to the constraint indicator currently under the cursor. constraint_hover_highlights: HashSet, + /// Sub-entity references related to that indicator. Polyline segments are + /// drawn separately so hovering one constraint does not light the whole chain. + constraint_hover_refs: Vec, + constraint_hover_wires: Vec, /// Color used to draw the selection highlight overlay wires (SELECTIONEFFECTCOLOR). pub selection_color: [f32; 4], /// Whether selection visual effect (glow/highlight) is enabled (SELECTIONEFFECT). @@ -2213,6 +2217,8 @@ impl Scene { refedit_keep: None, hover_highlight: None, constraint_hover_highlights: HashSet::default(), + constraint_hover_refs: Vec::new(), + constraint_hover_wires: Vec::new(), selection_color: crate::scene::model::wire_model::WireModel::SELECTED, selection_effect: true, transparency_display: true, @@ -5124,12 +5130,48 @@ impl Scene { handles } - pub fn set_constraint_hover_highlights(&mut self, handles: &[Handle]) { - let desired: HashSet<_> = handles.iter().copied().collect(); - if desired != self.constraint_hover_highlights { - self.constraint_hover_highlights = desired; - self.bump_selection(); + pub fn set_constraint_hover_highlights( + &mut self, + references: &[crate::scene::parametric_constraints::ParametricRef], + ) { + let mut references = references.to_vec(); + references.dedup(); + if references == self.constraint_hover_refs { + return; } + + let mut handles = HashSet::default(); + let mut wires = Vec::new(); + for reference in &references { + let Some(segment) = reference.segment_index() else { + handles.insert(reference.entity); + continue; + }; + let Some(entity) = self.document.get_entity(reference.entity) else { + continue; + }; + let Some(mut curve) = crate::entities::curve::entity_curve(entity) else { + handles.insert(reference.entity); + continue; + }; + let Some(segment_curve) = curve.curve.segments().into_iter().nth(segment) else { + continue; + }; + curve.curve = segment_curve; + let mut wire = WireModel::solid_f64( + format!("constraint-segment:{}:{segment}", reference.entity.value()), + curve.tessellate(8.0), + WireModel::HOVER, + false, + ); + wire.set_fixed_screen_width(2.0); + wires.push(wire); + } + + self.constraint_hover_refs = references; + self.constraint_hover_highlights = handles; + self.constraint_hover_wires = wires; + self.bump_selection(); } /// Keep the current selection visible and temporarily filter every other diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 082da8eb3..3d774584a 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -575,6 +575,37 @@ pub(crate) fn parametric_curve_ref_for_pick( } impl ConstraintKind { + pub const fn label(self) -> &'static str { + match self { + Self::Coincident => "Coincident", + Self::Horizontal => "Horizontal", + Self::Vertical => "Vertical", + Self::Parallel => "Parallel", + Self::Perpendicular => "Perpendicular", + Self::Equal => "Equal", + Self::Distance => "Distance", + Self::Angle => "Angle", + Self::Angle3Point => "3-point angle", + Self::Radius => "Radius", + Self::Tangent => "Tangent", + Self::Smooth => "Smooth", + Self::Concentric => "Concentric", + Self::CenterPoint => "Center point", + Self::Colinear => "Collinear", + Self::Midpoint => "Midpoint", + Self::Fixed => "Fixed", + Self::PointOnCurve => "Point on curve", + Self::EqualDistance => "Equal distance", + Self::Symmetric => "Symmetric", + Self::Diameter => "Diameter", + Self::DistanceX => "Horizontal distance", + Self::DistanceY => "Vertical distance", + Self::DistanceDirected => "Directed distance", + Self::Normal => "Normal", + Self::RigidSet => "Rigid set", + } + } + /// The short symbol a constraint glyph shows — matches the existing /// ribbon icons (`crate::modules::parametric::{tools,value}`) for /// the kinds that have a one-click button, so the same glyph means the @@ -640,7 +671,13 @@ pub(crate) fn glyph_placement( document: &acadrust::CadDocument, constraint: &ParametricConstraint, ) -> Option<(Vector3, Vector3)> { - let r = constraint.refs.first()?; + glyph_placements(document, constraint).into_iter().next() +} + +fn glyph_placement_for_reference( + document: &acadrust::CadDocument, + r: ParametricRef, +) -> Option<(Vector3, Vector3)> { let entity = document.get_entity(r.entity)?; let line_midpoint = |line: &acadrust::entities::Line| { Vector3::new( @@ -661,7 +698,7 @@ pub(crate) fn glyph_placement( entity, POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE - segment as i32, )?; - let [start, end] = constraint_segment_endpoints(document, *r)?; + let [start, end] = constraint_segment_endpoints(document, r)?; return Some((anchor, segment_normal(start, end))); } match (entity, r.marker) { @@ -704,6 +741,52 @@ pub(crate) fn glyph_placement( } } +fn glyph_placements( + document: &acadrust::CadDocument, + constraint: &ParametricConstraint, +) -> Vec<(Vector3, Vector3)> { + if constraint.kind == ConstraintKind::Parallel { + return constraint + .refs + .iter() + .filter_map(|reference| glyph_placement_for_reference(document, *reference)) + .collect(); + } + + let Some(first) = constraint.refs.first().copied() else { + return Vec::new(); + }; + let fallback = glyph_placement_for_reference(document, first); + if matches!( + constraint.kind, + ConstraintKind::Perpendicular | ConstraintKind::Tangent + ) { + if let [first, second, ..] = constraint.refs.as_slice() { + if let (Some(first_curve), Some(second_curve), Some((anchor, outward))) = ( + constraint_reference_curve_xy(document, *first), + constraint_reference_curve_xy(document, *second), + fallback, + ) { + if let Some(crossing) = cadkernel::geom2d::intersect( + &first_curve, + &second_curve, + cadkernel::geom2d::Tolerance::default(), + ) + .into_iter() + .next() + { + return vec![( + Vector3::new(crossing.point[0], crossing.point[1], anchor.z), + outward, + )]; + } + } + } + } + + fallback.into_iter().collect() +} + /// World-space locations that explain what a hovered constraint acts on. /// Point constraints expose their referenced point directly; curve relations /// expose the contact or intersection that makes the relation visible. @@ -1334,33 +1417,14 @@ impl super::Scene { .any(|reference| self.selected.contains(&reference.entity)); self.should_display_parametric_constraint(scope, c.id, selected, display_mode) }) - .filter_map(|c| { - let (anchor, outward) = glyph_placement(&self.document, c)?; - let screen = crate::scene::pick::grip::project_rte( - glam::DVec3::new(anchor.x, anchor.y, anchor.z), - view_rot, - eye, - bounds, - )?; - let outward_screen = crate::scene::pick::grip::project_rte( - glam::DVec3::new( - anchor.x + outward.x, - anchor.y + outward.y, - anchor.z + outward.z, - ), - view_rot, - eye, - bounds, - )?; - let direction = (outward_screen - screen).normalize_or(glam::Vec2::NEG_Y); - let point = iced::Point::new(bounds.x + screen.x, bounds.y + screen.y); + .flat_map(|c| { let is_conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id); let label = if show_values { glyph_label(c) } else { c.kind.glyph_symbol().to_string() }; - let hover_points = constraint_hover_points(&self.document, c) + let hover_points: Vec = constraint_hover_points(&self.document, c) .into_iter() .filter_map(|hover_point| { let projected = crate::scene::pick::grip::project_rte( @@ -1376,14 +1440,38 @@ impl super::Scene { (point.x.is_finite() && point.y.is_finite()).then_some(point) }) .collect(); - point.x.is_finite().then_some(( - c.id, - point, - direction.to_array(), - label, - is_conflicting, - hover_points, - )) + glyph_placements(&self.document, c) + .into_iter() + .filter_map(|(anchor, outward)| { + let screen = crate::scene::pick::grip::project_rte( + glam::DVec3::new(anchor.x, anchor.y, anchor.z), + view_rot, + eye, + bounds, + )?; + let outward_screen = crate::scene::pick::grip::project_rte( + glam::DVec3::new( + anchor.x + outward.x, + anchor.y + outward.y, + anchor.z + outward.z, + ), + view_rot, + eye, + bounds, + )?; + let direction = + (outward_screen - screen).normalize_or(glam::Vec2::NEG_Y); + let point = iced::Point::new(bounds.x + screen.x, bounds.y + screen.y); + point.x.is_finite().then(|| ( + c.id, + point, + direction.to_array(), + label.clone(), + is_conflicting, + hover_points.clone(), + )) + }) + .collect::>() }) .collect() } @@ -1580,6 +1668,88 @@ mod tests { glyph_placement(&document, &constraint(ParametricRef::segment(h(3), 0))).unwrap(); assert_eq!(anchor, Vector3::new(5.0, 0.0, 0.0)); assert_eq!(direction, Vector3::new(0.0, 10.0, 0.0)); + + let mut tangent_line = acadrust::entities::Line::from_points( + Vector3::new(-10.0, 5.0, 0.0), + Vector3::new(10.0, 5.0, 0.0), + ); + tangent_line.common.handle = h(4); + document.add_entity(acadrust::EntityType::Line(tangent_line)).unwrap(); + let relation = |id, kind, refs| ParametricConstraint { + id, + kind, + refs, + driving_param: None, + enabled: true, + native_origin: None, + rigid_points: Vec::new(), + distance_direction_type: 0, + distance_direction: None, + angle_sector: angle_sector::PARALLEL_COUNTERCLOCKWISE, + }; + let tangent = relation( + 1, + ConstraintKind::Tangent, + vec![ParametricRef::whole(h(4)), ParametricRef::whole(h(2))], + ); + assert_eq!( + glyph_placement(&document, &tangent).unwrap().0, + Vector3::new(0.0, 5.0, 0.0) + ); + + let mut rectangle = acadrust::entities::LwPolyline::from_points(vec![ + acadrust::types::Vector2::new(0.0, 0.0), + acadrust::types::Vector2::new(4.0, 0.0), + acadrust::types::Vector2::new(4.0, 2.0), + acadrust::types::Vector2::new(0.0, 2.0), + ]); + rectangle.common.handle = h(5); + rectangle.is_closed = true; + document.add_entity(acadrust::EntityType::LwPolyline(rectangle)).unwrap(); + let parallel = glyph_placements( + &document, + &relation( + 2, + ConstraintKind::Parallel, + vec![ParametricRef::segment(h(5), 0), ParametricRef::segment(h(5), 2)], + ), + ); + assert_eq!(parallel.len(), 2); + assert_eq!(parallel[0].0, Vector3::new(2.0, 0.0, 0.0)); + assert_eq!(parallel[1].0, Vector3::new(2.0, 2.0, 0.0)); + let perpendicular = relation( + 3, + ConstraintKind::Perpendicular, + vec![ParametricRef::segment(h(5), 3), ParametricRef::segment(h(5), 2)], + ); + assert_eq!( + glyph_placement(&document, &perpendicular).unwrap().0, + Vector3::new(0.0, 2.0, 0.0) + ); + } + + #[test] + fn polyline_constraint_hover_builds_only_the_referenced_segment() { + let mut scene = super::super::Scene::new(); + let handle = scene.add_entity(acadrust::EntityType::LwPolyline( + acadrust::entities::LwPolyline::from_points(vec![ + acadrust::types::Vector2::new(0.0, 0.0), + acadrust::types::Vector2::new(4.0, 0.0), + acadrust::types::Vector2::new(4.0, 2.0), + ]), + )); + + scene.set_constraint_hover_highlights(&[ParametricRef::segment(handle, 1)]); + + assert!(scene.constraint_hover_highlights.is_empty()); + assert_eq!(scene.constraint_hover_wires.len(), 1); + let wire = &scene.constraint_hover_wires[0]; + let points: Vec<_> = wire.points.iter().zip(&wire.points_low).map(|(high, low)| [ + high[0] as f64 + low[0] as f64, + high[1] as f64 + low[1] as f64, + high[2] as f64 + low[2] as f64, + ]).collect(); + assert_eq!(points, vec![[4.0, 0.0, 0.0], [4.0, 2.0, 0.0]]); } #[test] diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 4ebab412c..7469aed92 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -1008,6 +1008,16 @@ impl Scene { hover_changed = true; } hover_changed |= self.constraint_hover_highlights.remove(&h); + if self + .constraint_hover_refs + .iter() + .any(|reference| reference.entity == h) + { + self.constraint_hover_refs.clear(); + self.constraint_hover_wires.clear(); + self.constraint_hover_highlights.clear(); + hover_changed = true; + } self.hatches.remove(&h); self.images.remove(&h); self.meshes.remove(&h); diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 82bfd2eb7..5cbeb605e 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -4254,15 +4254,20 @@ impl Scene { Arc::new(Vec::new()) }; let preview_wires = if !show_live_overlay - || (self.interim_wire.is_none() && self.preview_wires.is_empty()) + || (self.interim_wire.is_none() + && self.preview_wires.is_empty() + && self.constraint_hover_wires.is_empty()) { Arc::new(Vec::new()) } else { - let mut v: Vec = Vec::with_capacity(self.preview_wires.len() + 1); + let mut v: Vec = Vec::with_capacity( + self.preview_wires.len() + self.constraint_hover_wires.len() + 1, + ); if let Some(iw) = &self.interim_wire { v.push(iw.clone()); } v.extend(self.preview_wires.iter().cloned()); + v.extend(self.constraint_hover_wires.iter().cloned()); Arc::new(v) }; let preview_hatches = if show_live_overlay { diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 001dc39d5..3501c2a6e 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -767,6 +767,7 @@ pub fn selection_overlay<'a>( crosshair: CrosshairOptions, selection_visual: SelectionVisualOptions, constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool, Vec)>, + constraint_glyph_tooltip: Option, ) -> Element<'a, Message> { canvas(SelectionCanvas { selection, @@ -791,6 +792,7 @@ pub fn selection_overlay<'a>( crosshair, selection_visual, constraint_glyphs, + constraint_glyph_tooltip, }) .width(Length::Fill) .height(Length::Fill) @@ -853,6 +855,8 @@ struct SelectionCanvas { /// state, whether the pill itself is the current click-to-select target, /// and the points to mark while the pill is hovered. constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool, Vec)>, + /// Localized kind name made visible after the app-level hover dwell. + constraint_glyph_tooltip: Option, } fn draw_grip_marker( @@ -1786,7 +1790,7 @@ impl canvas::Program for SelectionCanvas { let conflict_fg = theme.palette().danger.base.text; let selected_ring = theme.palette().primary.strong.color; for ((anchor, outward, label, is_conflicting, is_selected, _), tangent_offset) in - self.constraint_glyphs.iter().zip(offsets) + self.constraint_glyphs.iter().zip(offsets.iter().copied()) { if !anchor.x.is_finite() || !anchor.y.is_finite() { continue; @@ -1852,6 +1856,41 @@ impl canvas::Program for SelectionCanvas { frame.stroke(&first, stroke.clone()); frame.stroke(&second, stroke.clone()); } + if let Some(label) = &self.constraint_glyph_tooltip { + let (glyph_top_left, glyph_size) = constraint_glyph_box( + self.constraint_glyphs[index].0, + self.constraint_glyphs[index].1, + &self.constraint_glyphs[index].2, + offsets[index], + ); + let width = (label.chars().count() as f32 * 7.0 + 14.0).max(54.0); + let height = 24.0; + let left = glyph_top_left.x.clamp(2.0, (bounds.width - width - 2.0).max(2.0)); + let top = (glyph_top_left.y + glyph_size.height + 4.0) + .clamp(2.0, (bounds.height - height - 2.0).max(2.0)); + let tooltip = canvas::Path::rounded_rectangle( + Point::new(left, top), + Size::new(width, height), + 4.0.into(), + ); + frame.fill(&tooltip, theme.palette().background.strong.color); + frame.stroke( + &tooltip, + canvas::Stroke::default() + .with_color(theme.palette().background.strong.text) + .with_width(1.0), + ); + frame.fill_text(canvas::Text { + content: label.clone(), + position: Point::new(left + width * 0.5, top + height * 0.5), + color: theme.palette().background.strong.text, + size: iced::Pixels(12.0), + align_x: iced::alignment::Horizontal::Center.into(), + align_y: iced::alignment::Vertical::Center, + shaping: iced::advanced::text::Shaping::Advanced, + ..Default::default() + }); + } } } // Small cross at each acquired tracking point.