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() 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 => { Message::DynTabNext => {
let i = self.active_tab; let i = self.active_tab;
let n = self.tabs[i].dyn_fields.len(); let n = self.tabs[i].dyn_fields.len();
@@ -954,6 +963,17 @@ impl OpenCADStudio {
} }
Message::CommandHistoryPrev => { 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 // While autocomplete is showing suggestions, ↑ walks up
// that list. Otherwise it falls back to recall history. // that list. Otherwise it falls back to recall history.
let i = self.active_tab; let i = self.active_tab;
@@ -967,6 +987,12 @@ impl OpenCADStudio {
} }
Message::CommandHistoryNext => { 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; let i = self.active_tab;
if self.tabs[i].active_cmd.is_none() if self.tabs[i].active_cmd.is_none()
&& self.command_line.autocomplete_next() && self.command_line.autocomplete_next()
@@ -1142,6 +1168,15 @@ impl OpenCADStudio {
} }
Message::CommandFinalize => { 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 // A typed dynamic-input value commits as a point pick
// before the plain-Enter (on_enter) path runs. // before the plain-Enter (on_enter) path runs.
if let Some(task) = self.try_dyn_commit() { if let Some(task) = self.try_dyn_commit() {
@@ -1162,6 +1197,12 @@ impl OpenCADStudio {
} }
Message::CommandEscape => { 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. // Cancel layout rename / context menus first, then fall through.
let i_e = self.active_tab; let i_e = self.active_tab;
{ {
@@ -2166,6 +2207,14 @@ impl OpenCADStudio {
Message::ViewportLeftPress => { Message::ViewportLeftPress => {
let i = self.active_tab; 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 (p, vp_size) = {
let sel = self.tabs[i].scene.selection.borrow(); let sel = self.tabs[i].scene.selection.borrow();
let p = match sel.last_move_pos { 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 // Multi-functional grip popup (Phase 2). One bordered container
// menu items at the grip's screen position, with the selected // wraps a column of borderless item buttons so the popup reads
// item highlighted. Each item dispatches `GripMenuPick`. // as a single widget instead of stacked tiles.
if let Some(popup) = self.grip_popup.as_ref() { if let Some(popup) = self.grip_popup.as_ref() {
if !tab.is_start { if !tab.is_start {
let anchor_x = (popup.anchor.x + 12.0).max(0.0); let anchor_x = (popup.anchor.x + 12.0).max(0.0);
@@ -697,48 +697,61 @@ impl OpenCADStudio {
let label = item.label; let label = item.label;
let btn = button(text(label).size(12).color(Color::WHITE)) let btn = button(text(label).size(12).color(Color::WHITE))
.on_press(Message::GripMenuPick(idx)) .on_press(Message::GripMenuPick(idx))
.padding([4, 10]) .padding([3, 10])
.width(Fill)
.style(move |_: &Theme, status| iced::widget::button::Style { .style(move |_: &Theme, status| iced::widget::button::Style {
background: Some(Background::Color(match (is_sel, status) { background: Some(Background::Color(match (is_sel, status) {
(true, _) => Color { (true, _) => Color {
r: 0.20, r: 0.20,
g: 0.45, g: 0.45,
b: 0.95, 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, 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(), radius: 0.0.into(),
}, },
text_color: Color::WHITE, text_color: Color::WHITE,
..Default::default() ..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![ let popup_layer = iced::widget::column![
Space::new().height(iced::Length::Fixed(anchor_y)), Space::new().height(iced::Length::Fixed(anchor_y)),
iced::widget::row![ iced::widget::row![
Space::new().width(iced::Length::Fixed(anchor_x)), Space::new().width(iced::Length::Fixed(anchor_x)),
col, iced::widget::opaque(menu_panel),
], ],
] ]
.width(Fill) .width(Fill)