feat(grip): multi-functional hover popup menu

Phase 2 of the grip overhaul: when the cursor dwells on a selected
entity's grip the overlay opens a popup menu with entity-specific
options (Stretch / Add Vertex / Remove Vertex / Convert to
Arc-Line / …). Picking an item dispatches `apply_grip_menu`; the
default `Stretch` entry is a no-op that lets the user click the
grip to start the normal drag.

- `GripMenuItem` + `GripMenuAction` (full vocabulary covering
  stretch, add/remove vertex, convert to arc/line, reverse arrows,
  text-position variants, hatch parameters, tangent direction).
- `Grippable` trait gains `grip_menu` + `apply_grip_menu` with
  trait-default `Stretch`-only impls so every existing entity gets
  the popup for free.
- `EntityTypeOps` dispatches both methods across the same entity
  list as the existing grip / property machinery.
- `OpenCADStudio` carries `grip_hover` (dwell tracker) +
  `grip_popup` (open menu). `update_grip_hover` runs from
  `ViewportMove`: hit-tests grips, refreshes the dwell timer, and
  opens the popup after 600 ms. Cursor drift past 80 px or a grip
  click dismisses it.
- `view.rs` paints the popup as an absolute-positioned column of
  buttons at the grip's screen anchor; each button publishes
  `Message::GripMenuPick(idx)`.
- `LwPolyline` overrides `Grippable` manually (skips the standard
  macro) so its vertex grip exposes Add/Remove Vertex and its
  segment-midpoint grip exposes Add Vertex + Convert to Arc/Line.
  `apply_grip_menu` performs the edits: vertex insertion at the
  chord midpoint inheriting the previous bulge, vertex removal,
  and bulge toggle for the arc/line conversion.
This commit is contained in:
Hakan Seven 2026-05-29 09:30:54 +03:00
commit 6c1462932d
6 changed files with 443 additions and 2 deletions

View file

@ -23,6 +23,26 @@ pub struct TileDrag {
pub orient: TileEdgeOrient,
pub last_applied: f32,
}
/// Cursor dwelling over a grip. When `started.elapsed() >=` the popup
/// threshold the multi-functional menu opens (`grip_popup`).
#[derive(Clone, Debug)]
pub struct GripHover {
pub handle: acadrust::Handle,
pub grip_id: usize,
pub screen: iced::Point,
pub started: std::time::Instant,
}
/// Open multi-functional-grip popup state.
#[derive(Clone, Debug)]
pub struct GripPopup {
pub handle: acadrust::Handle,
pub grip_id: usize,
pub anchor: iced::Point,
pub items: Vec<crate::scene::object::GripMenuItem>,
pub selected: usize,
}
use crate::snap::Snapper;
use crate::ui::{AppMenu, CommandLine, Ribbon, StatusBar};
use acadrust::types::{Color as AcadColor, LineWeight};
@ -94,6 +114,14 @@ pub(super) struct OpenCADStudio {
/// reverting to the command-default when `has_base` flips. Cleared
/// on point commit / command start. See #35.
dyn_user_reshaped: bool,
/// Grip the cursor is currently dwelling on. Set when the cursor
/// stops within `GRIP_THRESHOLD_PX` of a grip; cleared when it
/// drifts away. The instant lets `ViewportMove` detect when the
/// dwell crosses the popup-open threshold.
grip_hover: Option<GripHover>,
/// Open multi-functional grip popup. Persists across mouse moves
/// until dismissed (click outside, ESC, cursor leaves the grip).
grip_popup: Option<GripPopup>,
/// Show the UCS icon in the bottom-left corner of model space (UCSICON).
show_ucs_icon: bool,
/// Whether the ViewCube 3D gizmo is visible in model space (NAVVCUBE).
@ -422,6 +450,9 @@ pub enum Message {
ViewportScroll(mouse::ScrollDelta),
ViewportExit,
ViewCubeSnap(CubeRegion),
/// User picked an item in the multi-functional grip popup menu —
/// the index is into `grip_popup.items`.
GripMenuPick(usize),
WindowResized(f32, f32),
/// Enter pressed globally — finalises the active command (no text-input involvement).
CommandFinalize,
@ -770,6 +801,8 @@ impl OpenCADStudio {
awaiting_vports: false,
tile_drag: None,
dyn_user_reshaped: false,
grip_hover: None,
grip_popup: None,
show_ucs_icon: true,
show_viewcube: true,
show_properties: true,

View file

@ -1766,6 +1766,11 @@ impl OpenCADStudio {
VIEWCUBE_PX,
));
// Multi-functional grip hover: detect cursor sitting on a
// selected entity's grip and, after a dwell, open the
// popup menu. See scene::object::GripMenuItem.
self.update_grip_hover(i, p);
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.last_move_pos = Some(p);
@ -2269,6 +2274,8 @@ impl OpenCADStudio {
origin_world: world,
last_world: world,
});
self.grip_hover = None;
self.grip_popup = None;
return Task::none();
}
}
@ -3060,6 +3067,38 @@ impl OpenCADStudio {
Task::none()
}
Message::GripMenuPick(idx) => {
let i = self.active_tab;
let Some(popup) = self.grip_popup.take() else {
return Task::none();
};
self.grip_hover = None;
let Some(item) = popup.items.get(idx).cloned() else {
return Task::none();
};
use crate::entities::traits::EntityTypeOps;
use crate::scene::object::GripMenuAction;
if matches!(item.action, GripMenuAction::Stretch) {
// Default action: nothing — user can click the grip
// to start the normal drag.
return Task::none();
}
self.push_undo_snapshot(i, item.label);
if let Some(entity) = self
.tabs[i]
.scene
.document
.get_entity_mut(popup.handle)
{
entity.apply_grip_menu(popup.grip_id, item.action);
}
self.tabs[i].scene.bump_geometry();
self.tabs[i].dirty = true;
self.refresh_selected_grips();
self.refresh_properties();
Task::none()
}
// ── Snap / mode toggles ───────────────────────────────────────
Message::ToggleSnapEnabled => {
self.snapper.toggle_global();
@ -5469,6 +5508,108 @@ impl OpenCADStudio {
}
}
/// Track cursor dwell over a selected entity's grip. Sets
/// `grip_hover` while the cursor sits within `GRIP_THRESHOLD_PX` of
/// a grip and opens `grip_popup` once the dwell exceeds the
/// threshold. Cursor drift clears both.
fn update_grip_hover(&mut self, i: usize, p: iced::Point) {
const HOVER_OPEN_MS: u128 = 600;
const POPUP_DISMISS_PX: f32 = 80.0;
if self.tabs[i].active_cmd.is_some()
|| self.tabs[i].active_grip.is_some()
|| self.tabs[i].selected_grips.is_empty()
{
self.grip_hover = None;
self.grip_popup = None;
return;
}
let Some(handle) = self.tabs[i].selected_handle else {
self.grip_hover = None;
self.grip_popup = None;
return;
};
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: vw,
height: vh,
};
let is_paper = self.tabs[i].scene.current_layout != "Model";
let hit = if is_paper {
let cam = self.tabs[i].scene.camera.borrow();
let aspect = if vh > 0.0 { vw / vh } else { 1.0 };
let half_h = cam.ortho_size();
let half_w = half_h * aspect;
let tx = cam.target.x;
let ty = cam.target.y;
drop(cam);
find_hit_grip_paper(
p,
&self.tabs[i].selected_grips,
tx,
ty,
half_w,
half_h,
bounds,
)
} else {
let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds);
find_hit_grip(p, &self.tabs[i].selected_grips, vp_mat, bounds)
};
match hit {
Some((grip_id, _, _)) => {
let same = self
.grip_hover
.as_ref()
.map_or(false, |h| h.handle == handle && h.grip_id == grip_id);
if !same {
self.grip_hover = Some(super::GripHover {
handle,
grip_id,
screen: p,
started: std::time::Instant::now(),
});
self.grip_popup = None;
} else if let Some(h) = self.grip_hover.as_mut() {
h.screen = p;
}
// Open popup once dwell crosses the threshold.
if self.grip_popup.is_none()
&& self
.grip_hover
.as_ref()
.map_or(false, |h| h.started.elapsed().as_millis() >= HOVER_OPEN_MS)
{
let entity_opt = self.tabs[i].scene.document.get_entity(handle);
if let Some(e) = entity_opt {
use crate::entities::traits::EntityTypeOps;
let items = e.grip_menu(grip_id);
if !items.is_empty() {
self.grip_popup = Some(super::GripPopup {
handle,
grip_id,
anchor: p,
items,
selected: 0,
});
}
}
}
}
None => {
self.grip_hover = None;
if let Some(popup) = &self.grip_popup {
let dx = p.x - popup.anchor.x;
let dy = p.y - popup.anchor.y;
if (dx * dx + dy * dy).sqrt() > POPUP_DISMISS_PX {
self.grip_popup = None;
}
}
}
}
}
/// Re-run the active command's preview hook against the current
/// cursor world position. Keyboard-driven point commits (typed
/// coordinates in the command line or dynamic input) don't fire a

View file

@ -684,6 +684,69 @@ impl OpenCADStudio {
}
}
// Multi-functional grip popup (Phase 2). Floating column of
// menu items at the grip's screen position, with the selected
// item highlighted. Each item dispatches `GripMenuPick`.
if let Some(popup) = self.grip_popup.as_ref() {
if !tab.is_start {
let anchor_x = (popup.anchor.x + 12.0).max(0.0);
let anchor_y = (popup.anchor.y + 12.0).max(0.0);
let mut col = column![].spacing(0);
for (idx, item) in popup.items.iter().enumerate() {
let is_sel = idx == popup.selected;
let label = item.label;
let btn = button(text(label).size(12).color(Color::WHITE))
.on_press(Message::GripMenuPick(idx))
.padding([4, 10])
.style(move |_: &Theme, status| iced::widget::button::Style {
background: Some(Background::Color(match (is_sel, status) {
(true, _) => Color {
r: 0.20,
g: 0.45,
b: 0.95,
a: 0.95,
},
(_, iced::widget::button::Status::Hovered) => Color {
r: 0.20,
g: 0.20,
b: 0.20,
a: 0.95,
},
_ => Color {
r: 0.10,
g: 0.10,
b: 0.10,
a: 0.92,
},
})),
border: Border {
color: Color {
r: 0.35,
g: 0.35,
b: 0.35,
a: 1.0,
},
width: 1.0,
radius: 0.0.into(),
},
text_color: Color::WHITE,
..Default::default()
});
col = col.push(iced::widget::opaque(btn));
}
let popup_layer = iced::widget::column![
Space::new().height(iced::Length::Fixed(anchor_y)),
iced::widget::row![
Space::new().width(iced::Length::Fixed(anchor_x)),
col,
],
]
.width(Fill)
.height(Fill);
viewport_stack = viewport_stack.push(popup_layer);
}
}
// Properties / layers panels carry no useful state on the Start tab.
// Replace the properties panel with a Recent Documents list there.
let properties_el: Element<'_, Message> = if tab.is_start {