feat(ui): compatible right-click context menu driven by command options

The viewport context menu is now built from a pure model
(ui::popup::context_menu) rendered by the overlay: while a command runs it
offers Enter / Cancel / every keyword of the command's options() with the
keyword shown as a hint, a Recent Input submenu and M2P; when idle it offers
Repeat, Recent Input, Clipboard, Undo/Redo with history labels, the edit
block (Erase/Move/Copy/Scale/Rotate/Mirror/Draw Order), Isolate, Pan/Zoom,
selection tools, Properties and Options. The default row is anchored under
the pointer and the menu is keyboard-navigable (arrows, Enter, mnemonic
letters; any other key closes it and types into the command line).

Right-click behaviour is a user preference (Options → User Preferences,
SHORTCUTMENU / SHORTCUTMENUDURATION): shortcut menu (commercial solutions default),
time-sensitive, or the previous Enter-first cycle.

LINE gains options() (Close/Undo/eXit).

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
PanosandClaude Opus 5 committed 2026-09-17 12:05:14 +03:00
1 parent 65c0fe5494
commit e623c053ab
19 files changed
+2019 -199

No files matched your search

+5 -25
View File
@@ -1752,37 +1752,17 @@ bg={bg_ms:.1}ms n={view_count}"
// the cursor position (canvas-relative) anchors the menu under
// the cursor instead of drifting into window-relative space.
if !tab.is_start {
let (ctx_pos, draworder_open) = {
let (ctx_pos, highlighted) = {
let sel = tab.scene.selection.borrow();
(sel.context_menu, sel.draworder_submenu)
(sel.context_menu, sel.context_menu_ui.highlighted)
};
if let Some(p) = ctx_pos {
let has_cmd = tab.active_cmd.is_some();
// Same guard as typed MTP/M2P and SnapOverrideMtp.
let has_point_step = tab.active_cmd.as_ref().is_some_and(|c| {
(!c.input_kind().wants_text() || c.point_step_accepts_keywords())
&& !c.needs_entity_pick()
});
let has_selection = !tab.scene.selected.is_empty();
let isolation_active = tab.scene.is_isolation_active();
let last_cmds: Vec<String> = self
.command_line
.recent_commands
.iter()
.rev()
.take(3)
.cloned()
.collect();
let menu = self.current_context_menu();
viewport_stack = viewport_stack.push(viewport_context_menu_overlay(
p,
command_line_inset,
has_cmd,
has_selection,
tab.scene.selected_constraint,
isolation_active,
last_cmds,
draworder_open,
has_point_step,
&menu,
highlighted,
));
}
}
+2
View File
@@ -297,6 +297,8 @@ impl OpenCADStudio {
show_viewcube: self.show_viewcube,
show_ucs_icon: self.show_ucs_icon,
ucs_icon_at_origin: self.ucs_icon_at_origin,
right_click_mode: self.right_click_mode,
right_click_hold_ms: self.right_click_hold_ms,
},
crate::ui::window::options::spacemouse::view(
self.spacemouse_preferences, self.spacemouse.status(),
+164 -151
View File
@@ -11,6 +11,7 @@ use iced::widget::{
};
use iced::{Background, Border, Color, Element, Event, Fill, Length, Rectangle, Size, Theme, Vector};
use crate::t;
use crate::ui::popup::context_menu::{ContextMenu, MenuItem, MenuRow, MENU_PAD_TOP, MENU_ROW_H};
pub(super) fn position_canvas_overlay<'a>(
anchor: iced::Point,
@@ -912,9 +913,15 @@ fn mtext_editor_content<'a>(
/// panel is laid out first, so edge flipping uses its actual translated size
/// instead of estimates. `bottom_inset` reserves overlaid controls such as the
/// command line.
///
/// `offset` shifts the preferred placement from the anchor before the fit /
/// flip logic runs (e.g. to put a specific row of a menu under the pointer).
/// When the shifted panel does not fit it flips to the other side of the
/// *original* anchor, so the pointer never ends up over an unintended row.
fn position_canvas_overlay_clamped<'a>(
anchor: iced::Point,
bottom_inset: f32,
offset: Vector,
panel: Element<'a, Message>,
) -> Element<'a, Message> {
Element::new(ClampedPin {
@@ -922,6 +929,7 @@ fn position_canvas_overlay_clamped<'a>(
anchor,
bottom_inset: bottom_inset.max(0.0),
gap: 0.0,
offset,
})
}
@@ -937,6 +945,7 @@ pub(super) fn position_canvas_overlay_near_cursor<'a>(
anchor: cursor,
bottom_inset: bottom_inset.max(0.0),
gap: 12.0,
offset: Vector::ZERO,
})
}
@@ -945,6 +954,8 @@ struct ClampedPin<'a> {
anchor: iced::Point,
bottom_inset: f32,
gap: f32,
/// Preferred placement shift from the anchor (applied before fitting).
offset: Vector,
}
impl Widget<Message, Theme, iced::Renderer> for ClampedPin<'_> {
@@ -985,7 +996,7 @@ impl Widget<Message, Theme, iced::Renderer> for ClampedPin<'_> {
let content = node.size();
let max_x = (max.width - MARGIN - content.width).max(MARGIN);
let right = self.anchor.x + self.gap;
let right = self.anchor.x + self.gap + self.offset.x;
let left = self.anchor.x - self.gap - content.width;
let x = if right <= max_x {
right.max(MARGIN)
@@ -997,7 +1008,7 @@ impl Widget<Message, Theme, iced::Renderer> for ClampedPin<'_> {
let max_y =
(max.height - self.bottom_inset - MARGIN - content.height).max(MARGIN);
let below = self.anchor.y + self.gap;
let below = self.anchor.y + self.gap + self.offset.y;
let above = self.anchor.y - self.gap - content.height;
let y = if below <= max_y {
below.max(MARGIN)
@@ -1109,26 +1120,21 @@ impl Widget<Message, Theme, iced::Renderer> for ClampedPin<'_> {
// ── Viewport right-click context menu ──────────────────────────────────────
/// Horizontal inset so the pointer rests inside the default row, not on the
/// panel's border, when the menu opens.
const MENU_CURSOR_INSET_X: f32 = 24.0;
/// Render the right-click context menu (rows from
/// `ui::popup::context_menu::build_context_menu`). The panel is placed so the
/// default row sits under the pointer: right-click then left-click in place
/// completes the step without moving the mouse (AutoCAD anchors its shortcut
/// menu the same way). `highlighted` is the keyboard highlight, if any.
pub(super) fn viewport_context_menu_overlay(
pos: iced::Point,
bottom_inset: f32,
has_cmd: bool,
has_selection: bool,
selected_constraint: Option<crate::scene::parametric_constraints::ConstraintId>,
isolation_active: bool,
last_cmds: Vec<String>,
draworder_open: bool,
has_point_step: bool,
menu: &ContextMenu,
highlighted: Option<usize>,
) -> Element<'static, Message> {
let item = |label: String, msg: Message| -> Element<'static, Message> {
button(text(label).size(12))
.on_press(msg)
.style(button::subtle)
.padding([4, 12])
.width(Fill)
.into()
};
let sep = || -> Element<'static, Message> {
container(iced::widget::Space::new().width(Fill).height(1))
.style(|theme: &Theme| container::Style {
@@ -1143,142 +1149,60 @@ pub(super) fn viewport_context_menu_overlay(
.into()
};
// Indented variant for sub-menu rows (e.g. Draw Order children).
let subitem = |label: String, msg: Message| -> Element<'static, Message> {
button(text(label).size(12))
.on_press(msg)
.style(button::subtle)
.padding(iced::Padding {
top: 4.0,
right: 12.0,
bottom: 4.0,
left: 26.0,
})
.width(Fill)
.into()
};
// Index into `menu.selectable()` of the row being rendered, so the
// keyboard highlight lands on the same row the model counts.
let mut sel_idx = 0usize;
let mut items: Vec<Element<'static, Message>> = Vec::new();
if has_cmd {
items.push(item(t!("Cancel").into_owned(), Message::CommandEscape));
items.push(item(t!("Enter").into_owned(), Message::CommandFinalize));
// MTP (`_M2P`) goes last, after Parallel: two picks, so not a
// `SnapType`, but same icon-only cell with hover tooltip.
if has_point_step {
items.push(sep());
items.push(item(
t!("Mid Between 2 Points (M2P)").into_owned(),
Message::SnapOverrideMtp,
));
}
} else {
if !last_cmds.is_empty() {
let last = last_cmds[0].clone();
items.push(item(
t!("Repeat %{last}", last = last).into_owned(),
Message::Command(last.to_uppercase()),
));
if last_cmds.len() > 1 {
for cmd in last_cmds.iter().skip(1) {
let c = cmd.clone();
items.push(item(c.clone(), Message::Command(c.to_uppercase())));
for row in &menu.rows {
match row {
MenuRow::Item(item) => {
let is_hl = highlighted == Some(sel_idx);
items.push(context_menu_row(item, 0.0, is_hl));
sel_idx += 1;
}
MenuRow::Separator => items.push(sep()),
MenuRow::Submenu {
id,
label,
items: children,
open,
} => {
let is_hl = highlighted == Some(sel_idx);
let caret = if *open {
crate::ui::icons::themed_arrow_down(9.0)
} else {
crate::ui::icons::themed_arrow_right(9.0)
};
let content = row![
text(label.clone()).size(12),
iced::widget::Space::new().width(Fill),
caret,
]
.align_y(iced::Center);
let enabled = !children.is_empty();
let mut btn = button(content)
.padding([4, 12])
.width(Fill)
.height(Length::Fixed(MENU_ROW_H))
.style(move |theme: &Theme, status| context_menu_row_style(theme, status, is_hl));
if enabled {
btn = btn.on_press(Message::ContextMenuSubmenuToggle(*id));
}
items.push(btn.into());
sel_idx += 1;
if *open {
for child in children {
let is_hl = highlighted == Some(sel_idx);
items.push(context_menu_row(child, 14.0, is_hl));
sel_idx += 1;
}
}
}
items.push(sep());
}
if has_selection {
items.push(item(t!("Delete").into_owned(), Message::DeleteSelected));
items.push(item(
t!("Move").into_owned(),
Message::Command("MOVE".to_string()),
));
items.push(item(
t!("Copy").into_owned(),
Message::Command("COPY".to_string()),
));
items.push(sep());
let do_caret = if draworder_open {
crate::ui::icons::themed_arrow_down(9.0)
} else {
crate::ui::icons::themed_arrow_right(9.0)
};
items.push(
button(
row![
text(t!("Draw Order").into_owned()).size(12),
iced::widget::Space::new().width(Fill),
do_caret,
]
.align_y(iced::Center),
)
.on_press(Message::DrawOrderSubmenuToggle)
.style(button::subtle)
.padding([4, 12])
.width(Fill)
.into(),
);
if draworder_open {
items.push(subitem(
t!("Bring to Front").into_owned(),
Message::Command("DRAWORDER F".to_string()),
));
items.push(subitem(
t!("Send to Back").into_owned(),
Message::Command("DRAWORDER B".to_string()),
));
items.push(subitem(
t!("Bring Above Object").into_owned(),
Message::DrawOrderPickRef(true),
));
items.push(subitem(
t!("Send Under Object").into_owned(),
Message::DrawOrderPickRef(false),
));
}
items.push(sep());
items.push(item(
t!("Isolate Objects").into_owned(),
Message::Command("ISOLATEOBJECTS".to_string()),
));
items.push(item(
t!("Hide Objects").into_owned(),
Message::Command("HIDEOBJECTS".to_string()),
));
items.push(sep());
items.push(item(t!("Select Similar").into_owned(), Message::SelectSimilar));
items.push(item(
t!("Invert Selection").into_owned(),
Message::InvertSelection,
));
} else if let Some(id) = selected_constraint {
// A selected constraint-glyph pill gets its own minimal menu —
// none of the entity-selection actions above (Move/Copy/Isolate/
// Select Similar) apply to it.
items.push(item(
t!("Delete").into_owned(),
Message::PropConstraintDelete(id),
));
items.push(sep());
}
if isolation_active {
items.push(item(
t!("End Object Isolation").into_owned(),
Message::Command("UNISOLATEOBJECTS".to_string()),
));
}
items.push(item(
t!("Select All").into_owned(),
Message::Command("SELECTALL".to_string()),
));
items.push(item(t!("Quick Select...").into_owned(), Message::QSelectOpen));
items.push(item(
t!("Zoom Extents").into_owned(),
Message::Command("ZOOM EXTENTS".to_string()),
));
}
let menu_col = column(items).spacing(0).width(Length::Fixed(180.0));
let menu_col = column(items).spacing(0).width(Length::Fixed(menu.width));
let menu_col = scrollable(menu_col)
.height(Length::Shrink)
.direction(scrollable::Direction::Vertical(
@@ -1287,12 +1211,101 @@ pub(super) fn viewport_context_menu_overlay(
.scroller_width(6),
));
let menu = container(menu_col)
let panel = container(menu_col)
.style(container::bordered_box)
.padding([4, 0])
.width(Length::Fixed(180.0));
.padding([MENU_PAD_TOP as u16, 0])
.width(Length::Fixed(menu.width));
position_canvas_overlay_clamped(pos, bottom_inset, menu.into())
// Shift the panel so the default row's centre line is under the pointer.
let offset = Vector::new(
-MENU_CURSOR_INSET_X,
-(menu.default_row_y() + MENU_ROW_H * 0.5),
);
position_canvas_overlay_clamped(pos, bottom_inset, offset, panel.into())
}
/// One menu row: label (bold for the default row, "✓ "-prefixed when
/// checked), and the keyword hint right-aligned and dimmed. Disabled rows
/// render without a press handler and with faded text.
fn context_menu_row(item: &MenuItem, indent: f32, highlighted: bool) -> Element<'static, Message> {
let label = if item.checked {
format!("✓ {}", item.label)
} else {
item.label.clone()
};
let mut label_text = text(label).size(12);
if item.default {
label_text = label_text.font(iced::Font {
weight: iced::font::Weight::Bold,
..iced::Font::DEFAULT
});
}
let enabled = item.enabled;
let mut content = row![label_text].spacing(8).align_y(iced::Center);
if let Some(hint) = item.hint.as_ref() {
content = content
.push(iced::widget::Space::new().width(Fill))
.push(
text(hint.clone())
.size(11)
.style(move |theme: &Theme| {
let palette = theme.palette();
let base = if highlighted {
palette.primary.strong.text
} else {
palette.background.base.text
};
iced::widget::text::Style {
color: Some(base.scale_alpha(if enabled { 0.6 } else { 0.35 })),
}
}),
);
}
let mut btn = button(content)
.padding(iced::Padding {
top: 4.0,
right: 12.0,
bottom: 4.0,
left: 12.0 + indent,
})
.width(Fill)
.height(Length::Fixed(MENU_ROW_H))
.style(move |theme: &Theme, status| context_menu_row_style(theme, status, highlighted));
if enabled {
btn = btn.on_press(Message::ContextMenuPick(item.action.clone()));
}
btn.into()
}
/// Row colours: keyboard highlight uses the accent (as the grip popup does),
/// hover the subtle background tint, disabled rows faded text.
fn context_menu_row_style(theme: &Theme, status: button::Status, highlighted: bool) -> button::Style {
let palette = theme.palette();
let (background, text_color) = if highlighted {
(
Some(Background::Color(palette.primary.strong.color)),
palette.primary.strong.text,
)
} else {
match status {
button::Status::Hovered | button::Status::Pressed => (
Some(Background::Color(palette.background.weak.color)),
palette.background.weak.text,
),
button::Status::Disabled => (None, palette.background.base.text.scale_alpha(0.42)),
button::Status::Active => (None, palette.background.base.text),
}
};
button::Style {
background,
text_color,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 0.0.into(),
},
..Default::default()
}
}
/// One-shot snap override menu (Shift+RMB, #337): a cursor-anchored grid of