feat(grip): keyboard nav + click-outside dismiss + single-widget popup

- Popup items now render inside one bordered container with
  borderless buttons so the menu reads as a single widget instead
  of stacked tiles.
- Arrow Up / Down (and Tab) walk `grip_popup.selected` while the
  popup is open; Enter commits the highlighted item by re-emitting
  `GripMenuPick`; Escape dismisses without acting; the underlying
  handlers (`CommandHistoryPrev/Next`, `CommandFinalize`,
  `CommandEscape`, `DynTabNext`) keep their normal behaviour when
  no popup is open.
- A press inside the viewport that reaches the mouse_area (i.e. not
  on a popup item) closes the popup and consumes the click — the
  user's next press then starts a normal selection / drag.
This commit is contained in:
Hakan Seven committed 2026-05-29 09:51:12 +03:00
1 parent a4c65649e1
commit 391aa3a76f
2 files changed
+89 -27

No files matched your search

+49
View File
@@ -937,6 +937,15 @@ impl OpenCADStudio {
self.focus_cmd_input()
}
Message::DynTabNext if self.grip_popup.is_some() => {
if let Some(popup) = self.grip_popup.as_mut() {
if !popup.items.is_empty() {
popup.selected = (popup.selected + 1) % popup.items.len();
}
}
Task::none()
}
Message::DynTabNext => {
let i = self.active_tab;
let n = self.tabs[i].dyn_fields.len();
@@ -954,6 +963,17 @@ impl OpenCADStudio {
}
Message::CommandHistoryPrev => {
// Grip popup wins first — arrow keys walk its items.
if let Some(popup) = self.grip_popup.as_mut() {
if !popup.items.is_empty() {
popup.selected = if popup.selected == 0 {
popup.items.len() - 1
} else {
popup.selected - 1
};
}
return Task::none();
}
// While autocomplete is showing suggestions, ↑ walks up
// that list. Otherwise it falls back to recall history.
let i = self.active_tab;
@@ -967,6 +987,12 @@ impl OpenCADStudio {
}
Message::CommandHistoryNext => {
if let Some(popup) = self.grip_popup.as_mut() {
if !popup.items.is_empty() {
popup.selected = (popup.selected + 1) % popup.items.len();
}
return Task::none();
}
let i = self.active_tab;
if self.tabs[i].active_cmd.is_none()
&& self.command_line.autocomplete_next()
@@ -1142,6 +1168,15 @@ impl OpenCADStudio {
}
Message::CommandFinalize => {
// Grip popup open → Enter commits the highlighted item.
if self.grip_popup.is_some() {
let idx = self
.grip_popup
.as_ref()
.map(|p| p.selected)
.unwrap_or(0);
return Task::done(Message::GripMenuPick(idx));
}
// A typed dynamic-input value commits as a point pick
// before the plain-Enter (on_enter) path runs.
if let Some(task) = self.try_dyn_commit() {
@@ -1162,6 +1197,12 @@ impl OpenCADStudio {
}
Message::CommandEscape => {
// Grip popup intercepts Escape — dismisses the menu
// without doing anything else.
if self.grip_popup.take().is_some() {
self.grip_hover = None;
return Task::none();
}
// Cancel layout rename / context menus first, then fall through.
let i_e = self.active_tab;
{
@@ -2166,6 +2207,14 @@ impl OpenCADStudio {
Message::ViewportLeftPress => {
let i = self.active_tab;
// Click anywhere outside the popup dismisses it. The
// menu's own buttons live above this mouse_area, so a
// press that reaches here means the cursor is not on
// any menu item.
if self.grip_popup.take().is_some() {
self.grip_hover = None;
return Task::none();
}
let (p, vp_size) = {
let sel = self.tabs[i].scene.selection.borrow();
let p = match sel.last_move_pos {
+40 -27
View File
@@ -684,9 +684,9 @@ 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`.
// Multi-functional grip popup (Phase 2). One bordered container
// wraps a column of borderless item buttons so the popup reads
// as a single widget instead of stacked tiles.
if let Some(popup) = self.grip_popup.as_ref() {
if !tab.is_start {
let anchor_x = (popup.anchor.x + 12.0).max(0.0);
@@ -697,48 +697,61 @@ impl OpenCADStudio {
let label = item.label;
let btn = button(text(label).size(12).color(Color::WHITE))
.on_press(Message::GripMenuPick(idx))
.padding([4, 10])
.padding([3, 10])
.width(Fill)
.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,
(_, iced::widget::button::Status::Hovered) => Color {
r: 0.22,
g: 0.22,
b: 0.22,
a: 1.0,
},
_ => Color::TRANSPARENT,
})),
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 0.0.into(),
},
text_color: Color::WHITE,
..Default::default()
});
col = col.push(iced::widget::opaque(btn));
col = col.push(btn);
}
let menu_panel = container(col)
.padding(2)
.width(iced::Length::Shrink)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color {
r: 0.10,
g: 0.10,
b: 0.10,
a: 0.95,
})),
border: Border {
color: Color {
r: 0.40,
g: 0.40,
b: 0.40,
a: 1.0,
},
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
});
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,
iced::widget::opaque(menu_panel),
],
]
.width(Fill)