Files
OneCAD-Draft/src/ui/popup/context_menu.rs
T
Hakan SevenandClaude Opus 5.5 ecda4b95dc test: pin the MCP tool schema and context menu height after Count
Count adds automation actions to the MCP tool surface and a Count row to
the idle context menu; update the pinned digest and the expected height.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 09:40:31 +03:00

1109 lines
40 KiB
Rust

//! Viewport right-click context menu — the *model*.
//!
//! `build_context_menu` is a pure function from the app's current state
//! (`MenuContext`) to the rows the menu shows (`ContextMenu`). It knows nothing
//! about iced: the view renders the rows, and the keyboard handler in `update`
//! walks the same rows to resolve Up/Down/Enter/mnemonic picks, so what the
//! user sees and what a keypress selects can never diverge.
//!
//! The layout covers the shortcut menus of commercial solutions, which the user base already
//! knows: while a command runs the menu offers Enter / Cancel / the command's
//! own keyword options (with the keyword shown as a hint, so the menu also
//! teaches the typed shortcut); when idle it offers Repeat / Recent Input /
//! Clipboard / Undo-Redo / Isolate / Pan-Zoom / selection tools; during a grip
//! edit it offers the grip modes.
use crate::command::CmdOption;
use crate::scene::parametric_constraints::ConstraintId;
use crate::scene::pick::grip::GripEditMode;
use crate::snap::SnapType;
use crate::t;
/// Fixed row height used by the renderer, so `ContextMenu::default_row_y`
/// (which anchors the default row under the cursor) agrees with the layout.
pub const MENU_ROW_H: f32 = 22.0;
/// Height of a separator row (1 px line plus its vertical padding).
pub const MENU_SEP_H: f32 = 7.0;
/// Vertical padding inside the menu panel above the first row.
pub const MENU_PAD_TOP: f32 = 4.0;
/// Column width without keyword hints (the width the menu always had).
pub const MENU_WIDTH: f32 = 200.0;
/// Column width when any row carries a right-aligned keyword hint.
pub const MENU_WIDTH_WITH_HINTS: f32 = 236.0;
/// How many recent entries the Recent Input / Repeat lists show.
pub const RECENT_LIMIT: usize = 10;
/// Identifies an inline-expanding (accordion) submenu. Only one is open at a
/// time; the open one is tracked in `SelectionState::context_menu_ui`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SubmenuId {
RecentInput,
Clipboard,
DrawOrder,
Isolate,
SnapOverrides,
}
/// Glyph drawn in a row's icon gutter (commercial solutions show one for object snaps
/// and the navigation rows).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MenuIcon {
Snap(SnapType),
Mtp,
/// Monochrome interface glyph, drawn in the text colour.
Chrome(&'static [u8]),
/// A command's ribbon icon, drawn in its own colours.
Tool(&'static [u8]),
}
/// What a grip-menu row does (the grip-mode shortcut menu of commercial solutions).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GripMenuCmd {
Stretch,
Move,
Rotate,
Scale,
Mirror,
BasePoint,
CopyToggle,
Undo,
Exit,
}
/// Everything a menu row can do. Translated into the app's existing messages
/// by `OpenCADStudio::on_context_menu_pick`, so a row does exactly what the
/// equivalent typed input / shortcut does.
#[derive(Clone, Debug, PartialEq)]
pub enum MenuAction {
/// Enter — finish / accept the current step (`CommandFinalize`).
Enter,
/// Escape — cancel the running command (`CommandEscape`).
Cancel,
/// Feed a command option keyword (`CommandOptionPick`). Empty = Enter.
Option(String),
/// Re-feed a recent typed token (point, distance, keyword) to the
/// running command.
FeedInput(String),
/// Run a command line string (`Message::Command`).
Command(String),
/// One-shot "Mid Between 2 Points" snap override.
Mtp,
/// One-shot object snap override for the next pick.
SnapOverride(SnapType),
/// Next pick ignores object snaps.
SnapOverrideNone,
DeleteSelected,
SelectSimilar,
InvertSelection,
DeselectAll,
QuickSelect,
Properties,
Options,
Undo,
Redo,
DrawOrderPickRef(bool),
ConstraintDelete(ConstraintId),
Grip(GripMenuCmd),
/// Expand / collapse an accordion submenu (never "picked" as a command).
ToggleSubmenu(SubmenuId),
}
/// One selectable row.
#[derive(Clone, Debug, PartialEq)]
pub struct MenuItem {
pub label: String,
/// Right-aligned keyword hint, e.g. `"C"` for Close, `"⏎"` for the Enter
/// row. Teaches the typed shortcut without cluttering the label.
pub hint: Option<String>,
/// Uppercase letter that picks this row from the keyboard while the menu
/// is open. Derived from the option keyword's first character.
pub mnemonic: Option<char>,
pub action: MenuAction,
/// The row Enter picks when nothing is highlighted; rendered bold and
/// anchored under the cursor when the menu opens.
pub default: bool,
pub enabled: bool,
/// Rendered with a leading check mark (toggle / current-state rows).
pub checked: bool,
/// Glyph in the icon gutter.
pub icon: Option<MenuIcon>,
}
impl MenuItem {
fn new(label: impl Into<String>, action: MenuAction) -> Self {
Self {
label: label.into(),
hint: None,
mnemonic: None,
action,
default: false,
enabled: true,
checked: false,
icon: None,
}
}
fn icon(mut self, icon: MenuIcon) -> Self {
self.icon = Some(icon);
self
}
/// The command's ribbon icon, when the ribbon has one.
fn command_icon(mut self, command: &str) -> Self {
self.icon = command_icon(command);
self
}
fn hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
fn mnemonic(mut self, ch: Option<char>) -> Self {
self.mnemonic = ch;
self
}
fn default(mut self) -> Self {
self.default = true;
self
}
fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
fn checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum MenuRow {
Item(MenuItem),
Separator,
Submenu {
id: SubmenuId,
label: String,
icon: Option<MenuIcon>,
items: Vec<MenuItem>,
open: bool,
},
}
fn command_icon(command: &str) -> Option<MenuIcon> {
crate::modules::registry::command_icon(command).map(MenuIcon::Tool)
}
/// A keyboard-navigable row in display order: either an item or a submenu
/// header. Separators and the children of collapsed submenus are skipped.
#[derive(Clone, Debug, PartialEq)]
pub struct Selectable {
pub action: MenuAction,
pub mnemonic: Option<char>,
pub enabled: bool,
/// `Some(id)` for a submenu header row.
pub header: Option<SubmenuId>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ContextMenu {
pub rows: Vec<MenuRow>,
pub width: f32,
}
impl ContextMenu {
/// Keyboard-navigable rows in display order. The index into this list is
/// the highlight index kept in `ContextMenuUi::highlighted`.
pub fn selectable(&self) -> Vec<Selectable> {
let mut out = Vec::new();
for row in &self.rows {
match row {
MenuRow::Item(item) => out.push(Selectable {
action: item.action.clone(),
mnemonic: item.mnemonic,
enabled: item.enabled,
header: None,
}),
MenuRow::Separator => {}
MenuRow::Submenu { id, items, open, .. } => {
out.push(Selectable {
action: MenuAction::ToggleSubmenu(*id),
mnemonic: None,
enabled: !items.is_empty(),
header: Some(*id),
});
if *open {
for item in items {
out.push(Selectable {
action: item.action.clone(),
mnemonic: item.mnemonic,
enabled: item.enabled,
header: None,
});
}
}
}
}
}
out
}
/// Index (into `selectable()`) of the default row, else the first row.
pub fn default_index(&self) -> usize {
let mut idx = 0;
for row in &self.rows {
match row {
MenuRow::Item(item) => {
if item.default {
return idx;
}
idx += 1;
}
MenuRow::Separator => {}
MenuRow::Submenu { items, open, .. } => {
idx += 1;
if *open {
for item in items {
if item.default {
return idx;
}
idx += 1;
}
}
}
}
}
0
}
/// The enabled row whose mnemonic is `ch`, searching forward from (and
/// excluding) `from` and wrapping, so repeated presses cycle through rows
/// sharing a letter (PLINE's CEnter / CLose both answer to `C`).
pub fn find_mnemonic(&self, ch: char, from: Option<usize>) -> Option<usize> {
let ch = ch.to_ascii_uppercase();
let rows = self.selectable();
if rows.is_empty() {
return None;
}
let start = from.map_or(0, |f| (f + 1) % rows.len());
(0..rows.len())
.map(|k| (start + k) % rows.len())
.find(|&i| rows[i].enabled && rows[i].mnemonic == Some(ch))
}
/// Vertical offset from the panel's top edge to the top of the default
/// row, using the renderer's fixed row metrics.
pub fn default_row_y(&self) -> f32 {
let mut y = MENU_PAD_TOP;
for row in &self.rows {
match row {
MenuRow::Item(item) => {
if item.default {
return y;
}
y += MENU_ROW_H;
}
MenuRow::Separator => y += MENU_SEP_H,
MenuRow::Submenu { items, open, .. } => {
y += MENU_ROW_H;
if *open {
for item in items {
if item.default {
return y;
}
y += MENU_ROW_H;
}
}
}
}
}
MENU_PAD_TOP
}
/// Total panel height (rows plus the panel's vertical padding).
pub fn height(&self) -> f32 {
let mut h = MENU_PAD_TOP * 2.0;
for row in &self.rows {
match row {
MenuRow::Item(_) => h += MENU_ROW_H,
MenuRow::Separator => h += MENU_SEP_H,
MenuRow::Submenu { items, open, .. } => {
h += MENU_ROW_H;
if *open {
h += items.len() as f32 * MENU_ROW_H;
}
}
}
}
h
}
fn has_hints(&self) -> bool {
self.rows.iter().any(|row| match row {
MenuRow::Item(item) => item.hint.is_some(),
MenuRow::Separator => false,
MenuRow::Submenu { items, .. } => items.iter().any(|item| item.hint.is_some()),
})
}
}
/// State of a grip edit the menu was opened over.
#[derive(Clone, Debug, PartialEq)]
pub struct GripMenuContext {
pub mode: GripEditMode,
pub copy_on: bool,
/// The grip has been dragged away from its origin (Undo snaps it back).
pub moved: bool,
}
/// Inputs the builder needs, gathered by `OpenCADStudio::context_menu_context`.
#[derive(Clone, Debug)]
pub enum MenuContext {
/// A command is running: its current step's options.
Command {
options: Vec<CmdOption>,
/// The step takes a point pick (the M2P override applies).
has_point_step: bool,
/// Recently typed tokens (newest first) for the Recent Input submenu.
recent_inputs: Vec<String>,
},
/// Nothing running.
Idle {
has_selection: bool,
selected_constraint: Option<ConstraintId>,
isolation_active: bool,
/// Recently run commands, newest first.
recent_cmds: Vec<String>,
clipboard_nonempty: bool,
undo_label: Option<String>,
redo_label: Option<String>,
props_open: bool,
},
/// A grip is hot / being dragged.
Grip(GripMenuContext),
}
/// Build the menu rows for `ctx`, with `open_submenu` expanded.
pub fn build_context_menu(ctx: &MenuContext, open_submenu: Option<SubmenuId>) -> ContextMenu {
let rows = match ctx {
MenuContext::Command {
options,
has_point_step,
recent_inputs,
} => command_rows(options, *has_point_step, recent_inputs, open_submenu),
MenuContext::Idle {
has_selection,
selected_constraint,
isolation_active,
recent_cmds,
clipboard_nonempty,
undo_label,
redo_label,
props_open,
} => idle_rows(
*has_selection,
*selected_constraint,
*isolation_active,
recent_cmds,
*clipboard_nonempty,
undo_label.as_deref(),
redo_label.as_deref(),
*props_open,
open_submenu,
),
MenuContext::Grip(grip) => grip_rows(grip),
};
let mut menu = ContextMenu { rows, width: MENU_WIDTH };
if menu.has_hints() {
menu.width = MENU_WIDTH_WITH_HINTS;
}
menu
}
/// Keyboard letter for an option keyword: its first ASCII alphanumeric.
fn mnemonic_for(keyword: &str) -> Option<char> {
keyword
.chars()
.find(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_uppercase())
}
fn command_rows(
options: &[CmdOption],
has_point_step: bool,
recent_inputs: &[String],
open_submenu: Option<SubmenuId>,
) -> Vec<MenuRow> {
let mut rows = Vec::new();
// Ordered by how often a drafter reaches for each row: Enter / Cancel
// under the pointer, the command's own keywords right below them (the
// choices that change from step to step), then the recall and snap
// helpers, and the transparent navigation commands last.
//
// A command may name its own finish action (PLINE "Done", OFFSET's
// "<0.5000>" default). That row *is* Enter: it is not listed twice, and
// a default value is shown as the Enter row's hint.
let finish = options.iter().find(|o| o.keyword.is_empty());
let enter_hint = match finish {
Some(o) if o.label.starts_with('<') => o.label.clone(),
_ => "⏎".to_string(),
};
rows.push(MenuRow::Item(
MenuItem::new(t!("Enter").into_owned(), MenuAction::Enter).hint(enter_hint).default(),
));
rows.push(MenuRow::Item(
MenuItem::new(t!("Cancel").into_owned(), MenuAction::Cancel).hint("Esc"),
));
let keyword_opts: Vec<&CmdOption> = options.iter().filter(|o| !o.keyword.is_empty()).collect();
if !keyword_opts.is_empty() {
rows.push(MenuRow::Separator);
for opt in keyword_opts {
rows.push(MenuRow::Item(
MenuItem::new(opt.label.clone(), MenuAction::Option(opt.keyword.clone()))
.hint(opt.keyword.clone())
.mnemonic(mnemonic_for(&opt.keyword)),
));
}
}
rows.push(MenuRow::Separator);
rows.push(recent_input_submenu(
recent_inputs
.iter()
.take(RECENT_LIMIT)
.map(|tok| MenuItem::new(tok.clone(), MenuAction::FeedInput(tok.clone())))
.collect(),
open_submenu,
));
if has_point_step {
rows.push(MenuRow::Submenu {
id: SubmenuId::SnapOverrides,
label: t!("Snap Overrides").into_owned(),
icon: None,
items: snap_override_items(),
open: open_submenu == Some(SubmenuId::SnapOverrides),
});
}
rows.push(MenuRow::Separator);
rows.extend(navigation_rows(true));
rows
}
/// Recent Input ▸ — always listed (commercial solutions keep the entry even when there
/// is nothing to recall yet; it is simply disabled then).
fn recent_input_submenu(items: Vec<MenuItem>, open_submenu: Option<SubmenuId>) -> MenuRow {
MenuRow::Submenu {
id: SubmenuId::RecentInput,
label: t!("Recent Input").into_owned(),
icon: None,
items,
open: open_submenu == Some(SubmenuId::RecentInput),
}
}
/// Snap Overrides ▸: one-shot object snaps for the next pick, in the
/// grouping (M2P, then the edge snaps, the curve snaps, the relation snaps,
/// None) plus the settings dialog.
fn snap_override_items() -> Vec<MenuItem> {
let snap = |label: &str, t: SnapType| {
MenuItem::new(t!(label).into_owned(), MenuAction::SnapOverride(t)).icon(MenuIcon::Snap(t))
};
vec![
MenuItem::new(t!("Mid Between 2 Points").into_owned(), MenuAction::Mtp).icon(MenuIcon::Mtp),
snap("Endpoint", SnapType::Endpoint),
snap("Midpoint", SnapType::Midpoint),
snap("Intersection", SnapType::Intersection),
snap("Apparent Intersection", SnapType::ApparentIntersection),
snap("Extension", SnapType::Extension),
snap("Center", SnapType::Center),
snap("Quadrant", SnapType::Quadrant),
snap("Tangent", SnapType::Tangent),
snap("Perpendicular", SnapType::Perpendicular),
snap("Parallel", SnapType::Parallel),
snap("Node", SnapType::Node),
snap("Insertion", SnapType::Insertion),
snap("Nearest", SnapType::Nearest),
MenuItem::new(t!("None").into_owned(), MenuAction::SnapOverrideNone),
MenuItem::new(
t!("Osnap Settings...").into_owned(),
MenuAction::Command("OSNAP".to_string()),
),
]
}
/// Pan / Zoom rows. While a command runs they execute transparently (the
/// `'` prefix, as in commercial solutions) so the command resumes afterwards.
fn navigation_rows(transparent: bool) -> Vec<MenuRow> {
let prefix = if transparent { "'" } else { "" };
vec![
MenuRow::Item(
MenuItem::new(t!("Pan").into_owned(), MenuAction::Command(format!("{prefix}PAN")))
.icon(MenuIcon::Chrome(crate::ui::icons::pan_icon())),
),
MenuRow::Item(
MenuItem::new(
t!("Zoom").into_owned(),
MenuAction::Command(format!("{prefix}ZOOM DYNAMIC")),
)
.icon(MenuIcon::Chrome(crate::ui::icons::zoom_icon())),
),
]
}
#[allow(clippy::too_many_arguments)]
fn idle_rows(
has_selection: bool,
selected_constraint: Option<ConstraintId>,
isolation_active: bool,
recent_cmds: &[String],
clipboard_nonempty: bool,
undo_label: Option<&str>,
redo_label: Option<&str>,
props_open: bool,
open_submenu: Option<SubmenuId>,
) -> Vec<MenuRow> {
let cmd = |label: String, name: &str| {
MenuItem::new(label, MenuAction::Command(name.to_string())).command_icon(name)
};
let mut rows = Vec::new();
// ── Repeat / Recent Input ──────────────────────────────────────────
if let Some(last) = recent_cmds.first() {
rows.push(MenuRow::Item(
MenuItem::new(
t!("Repeat %{last}", last = last.clone()).into_owned(),
MenuAction::Command(last.to_uppercase()),
)
.command_icon(last)
.default(),
));
}
rows.push(recent_input_submenu(
recent_cmds
.iter()
.take(RECENT_LIMIT)
.map(|c| {
MenuItem::new(c.clone(), MenuAction::Command(c.to_uppercase())).command_icon(c)
})
.collect(),
open_submenu,
));
rows.push(MenuRow::Separator);
// ── Clipboard ──────────────────────────────────────────────────────
rows.push(MenuRow::Submenu {
id: SubmenuId::Clipboard,
label: t!("Clipboard").into_owned(),
icon: None,
items: vec![
cmd(t!("Cut").into_owned(), "CUTCLIP").enabled(has_selection),
cmd(t!("Copy").into_owned(), "COPYCLIP").enabled(has_selection),
cmd(t!("Copy with Base Point").into_owned(), "COPYBASE").enabled(has_selection),
cmd(t!("Paste").into_owned(), "PASTECLIP").enabled(clipboard_nonempty),
cmd(t!("Paste as Block").into_owned(), "PASTEBLOCK").enabled(clipboard_nonempty),
cmd(t!("Paste to Original Coordinates").into_owned(), "PASTEORIG")
.enabled(clipboard_nonempty),
],
open: open_submenu == Some(SubmenuId::Clipboard),
});
rows.push(MenuRow::Separator);
// ── Undo / Redo ────────────────────────────────────────────────────
let undo = match undo_label {
Some(label) => t!("Undo %{label}", label = label.to_string()).into_owned(),
None => t!("Undo").into_owned(),
};
let redo = match redo_label {
Some(label) => t!("Redo %{label}", label = label.to_string()).into_owned(),
None => t!("Redo").into_owned(),
};
rows.push(MenuRow::Item(
MenuItem::new(undo, MenuAction::Undo)
.icon(MenuIcon::Chrome(crate::ui::icons::undo_icon()))
.hint("Ctrl+Z")
.enabled(undo_label.is_some()),
));
rows.push(MenuRow::Item(
MenuItem::new(redo, MenuAction::Redo)
.icon(MenuIcon::Chrome(crate::ui::icons::redo_icon()))
.hint("Ctrl+Y")
.enabled(redo_label.is_some()),
));
rows.push(MenuRow::Separator);
// ── Selection edit block (the "Edit" shortcut menu of commercial solutions) ──
if has_selection {
rows.push(MenuRow::Item(
MenuItem::new(t!("Erase").into_owned(), MenuAction::DeleteSelected)
.command_icon("ERASE"),
));
rows.push(MenuRow::Item(cmd(t!("Move").into_owned(), "MOVE")));
rows.push(MenuRow::Item(cmd(t!("Copy Selection").into_owned(), "COPY")));
rows.push(MenuRow::Item(cmd(t!("Scale").into_owned(), "SCALE")));
rows.push(MenuRow::Item(cmd(t!("Rotate").into_owned(), "ROTATE")));
rows.push(MenuRow::Item(cmd(t!("Mirror").into_owned(), "MIRROR")));
rows.push(MenuRow::Submenu {
id: SubmenuId::DrawOrder,
label: t!("Draw Order").into_owned(),
icon: command_icon("DRAWORDER_FRONT"),
items: vec![
cmd(t!("Bring to Front").into_owned(), "DRAWORDER F"),
cmd(t!("Send to Back").into_owned(), "DRAWORDER B"),
MenuItem::new(
t!("Bring Above Object").into_owned(),
MenuAction::DrawOrderPickRef(true),
),
MenuItem::new(
t!("Send Under Object").into_owned(),
MenuAction::DrawOrderPickRef(false),
),
],
open: open_submenu == Some(SubmenuId::DrawOrder),
});
rows.push(MenuRow::Separator);
} else if let Some(id) = selected_constraint {
// A selected constraint-glyph pill gets a minimal edit block — none
// of the entity actions above apply to it.
rows.push(MenuRow::Item(MenuItem::new(
t!("Delete").into_owned(),
MenuAction::ConstraintDelete(id),
)));
rows.push(MenuRow::Separator);
}
// ── Isolate ────────────────────────────────────────────────────────
rows.push(MenuRow::Submenu {
id: SubmenuId::Isolate,
label: t!("Isolate").into_owned(),
icon: Some(MenuIcon::Chrome(crate::ui::icons::ST_ISOLATE)),
items: vec![
cmd(t!("Isolate Objects").into_owned(), "ISOLATEOBJECTS").enabled(has_selection),
cmd(t!("Hide Objects").into_owned(), "HIDEOBJECTS").enabled(has_selection),
cmd(t!("End Object Isolation").into_owned(), "UNISOLATEOBJECTS")
.enabled(isolation_active),
],
open: open_submenu == Some(SubmenuId::Isolate),
});
rows.push(MenuRow::Separator);
// ── Navigation ─────────────────────────────────────────────────────
rows.extend(navigation_rows(false));
// The edit menu is long already; Zoom Extents stays on the idle menu
// (and on the middle button's double-click), as in commercial solutions.
if !has_selection {
rows.push(MenuRow::Item(cmd(t!("Zoom Extents").into_owned(), "ZOOM EXTENTS")));
}
rows.push(MenuRow::Separator);
// ── Selection tools ────────────────────────────────────────────────
if has_selection {
rows.push(MenuRow::Item(MenuItem::new(
t!("Select Similar").into_owned(),
MenuAction::SelectSimilar,
)));
rows.push(MenuRow::Item(MenuItem::new(
t!("Invert Selection").into_owned(),
MenuAction::InvertSelection,
)));
rows.push(MenuRow::Item(MenuItem::new(
t!("Deselect All").into_owned(),
MenuAction::DeselectAll,
)));
}
if !has_selection {
rows.push(MenuRow::Item(cmd(t!("Select All").into_owned(), "SELECTALL")));
}
rows.push(MenuRow::Item(MenuItem::new(
t!("Quick Select...").into_owned(),
MenuAction::QuickSelect,
)));
// Count the selected objects, or start a count.
rows.push(MenuRow::Item(if has_selection {
cmd(t!("Count Selection").into_owned(), "COUNT _C")
} else {
cmd(t!("Count").into_owned(), "COUNT")
}));
rows.push(MenuRow::Separator);
// ── Panels ─────────────────────────────────────────────────────────
rows.push(MenuRow::Item(
MenuItem::new(t!("Properties").into_owned(), MenuAction::Properties)
.command_icon("PROPERTIES")
.checked(props_open),
));
rows.push(MenuRow::Item(
MenuItem::new(t!("Options...").into_owned(), MenuAction::Options).command_icon("OPTIONS"),
));
rows
}
fn grip_rows(grip: &GripMenuContext) -> Vec<MenuRow> {
let g = |label: String, cmd: GripMenuCmd| {
let command = match cmd {
GripMenuCmd::Stretch => "STRETCH",
GripMenuCmd::Move => "MOVE",
GripMenuCmd::Rotate => "ROTATE",
GripMenuCmd::Scale => "SCALE",
GripMenuCmd::Mirror => "MIRROR",
GripMenuCmd::CopyToggle => "COPY",
GripMenuCmd::BasePoint | GripMenuCmd::Undo | GripMenuCmd::Exit => "",
};
MenuItem::new(label, MenuAction::Grip(cmd)).command_icon(command)
};
vec![
MenuRow::Item(MenuItem::new(t!("Enter").into_owned(), MenuAction::Enter).hint("⏎").default()),
MenuRow::Separator,
MenuRow::Item(
g(t!("Stretch").into_owned(), GripMenuCmd::Stretch)
.checked(matches!(grip.mode, GripEditMode::Stretch)),
),
MenuRow::Item(g(t!("Move").into_owned(), GripMenuCmd::Move)),
MenuRow::Item(g(t!("Rotate").into_owned(), GripMenuCmd::Rotate)),
MenuRow::Item(g(t!("Scale").into_owned(), GripMenuCmd::Scale)),
MenuRow::Item(g(t!("Mirror").into_owned(), GripMenuCmd::Mirror)),
MenuRow::Separator,
MenuRow::Item(g(t!("Base Point").into_owned(), GripMenuCmd::BasePoint)),
MenuRow::Item(g(t!("Copy").into_owned(), GripMenuCmd::CopyToggle).checked(grip.copy_on)),
MenuRow::Item(g(t!("Undo").into_owned(), GripMenuCmd::Undo).enabled(grip.moved)),
MenuRow::Separator,
MenuRow::Item(g(t!("Exit").into_owned(), GripMenuCmd::Exit).hint("Esc")),
]
}
#[cfg(test)]
mod tests {
use super::*;
/// Labels are translated at build time, so expectations go through the
/// same catalog (tests run under whatever UI language the host has).
fn tl(source: &str) -> String {
t!(source).into_owned()
}
fn labels(menu: &ContextMenu) -> Vec<String> {
let mut out = Vec::new();
for row in &menu.rows {
match row {
MenuRow::Item(item) => out.push(item.label.clone()),
MenuRow::Separator => out.push("---".into()),
MenuRow::Submenu { label, items, open, .. } => {
out.push(format!("{label} >"));
if *open {
out.extend(items.iter().map(|i| format!(" {}", i.label)));
}
}
}
}
out
}
fn actions(menu: &ContextMenu) -> Vec<MenuAction> {
menu.selectable().into_iter().map(|s| s.action).collect()
}
fn item_at(menu: &ContextMenu, idx: usize) -> &MenuItem {
match &menu.rows[idx] {
MenuRow::Item(item) => item,
other => panic!("expected item at {idx}, got {other:?}"),
}
}
fn line_ctx() -> MenuContext {
MenuContext::Command {
options: vec![CmdOption::new("Close", "C"), CmdOption::new("Undo", "U")],
has_point_step: true,
recent_inputs: Vec::new(),
}
}
#[test]
fn command_menu_lists_options_with_hints() {
let menu = build_context_menu(&line_ctx(), None);
// Enter / Cancel, the keywords, then the recall / snap helpers and
// the navigation rows.
assert_eq!(
labels(&menu),
vec![
tl("Enter"),
tl("Cancel"),
"---".into(),
tl("Close"),
tl("Undo"),
"---".into(),
format!("{} >", tl("Recent Input")),
format!("{} >", tl("Snap Overrides")),
"---".into(),
tl("Pan"),
tl("Zoom"),
]
);
let close = item_at(&menu, 3);
assert_eq!(close.hint.as_deref(), Some("C"));
assert_eq!(close.mnemonic, Some('C'));
assert_eq!(close.action, MenuAction::Option("C".into()));
assert!(item_at(&menu, 0).default);
assert_eq!(menu.default_index(), 0);
assert_eq!(menu.width, MENU_WIDTH_WITH_HINTS);
}
#[test]
fn enter_option_becomes_default_row() {
let ctx = MenuContext::Command {
options: vec![CmdOption::new("Arc", "A"), CmdOption::enter("Done")],
has_point_step: false,
recent_inputs: Vec::new(),
};
let menu = build_context_menu(&ctx, None);
// The finish option is the Enter row itself — listed once, as "Enter".
let done = item_at(&menu, 0);
assert_eq!(done.label, tl("Enter"));
assert!(done.default);
assert_eq!(done.action, MenuAction::Enter);
assert_eq!(done.hint.as_deref(), Some("⏎"));
assert_eq!(
actions(&menu)[..4],
[
MenuAction::Enter,
MenuAction::Cancel,
MenuAction::Option("A".into()),
MenuAction::ToggleSubmenu(SubmenuId::RecentInput),
]
);
// A default value shows on the Enter row as its hint (OFFSET <dist>).
let ctx = MenuContext::Command {
options: vec![CmdOption::enter("<0.5000>")],
has_point_step: false,
recent_inputs: Vec::new(),
};
let menu = build_context_menu(&ctx, None);
assert_eq!(item_at(&menu, 0).hint.as_deref(), Some("<0.5000>"));
}
#[test]
fn command_menu_without_options_has_only_enter_cancel() {
let ctx = MenuContext::Command {
options: Vec::new(),
has_point_step: false,
recent_inputs: Vec::new(),
};
let menu = build_context_menu(&ctx, None);
assert_eq!(
actions(&menu),
vec![
MenuAction::Enter,
MenuAction::Cancel,
MenuAction::ToggleSubmenu(SubmenuId::RecentInput),
MenuAction::Command("'PAN".into()),
MenuAction::Command("'ZOOM DYNAMIC".into()),
]
);
// Nothing to recall yet: the Recent Input header is disabled.
assert!(!menu.selectable()[2].enabled);
}
#[test]
fn recent_input_submenu_lists_tokens_when_open() {
let ctx = MenuContext::Command {
options: Vec::new(),
has_point_step: false,
recent_inputs: vec!["10,20".into(), "C".into()],
};
let closed = build_context_menu(&ctx, None);
assert_eq!(
actions(&closed)[..3],
[
MenuAction::Enter,
MenuAction::Cancel,
MenuAction::ToggleSubmenu(SubmenuId::RecentInput)
]
);
assert!(closed.selectable()[2].enabled);
let open = build_context_menu(&ctx, Some(SubmenuId::RecentInput));
assert_eq!(
actions(&open)[..5],
[
MenuAction::Enter,
MenuAction::Cancel,
MenuAction::ToggleSubmenu(SubmenuId::RecentInput),
MenuAction::FeedInput("10,20".into()),
MenuAction::FeedInput("C".into()),
]
);
}
#[test]
fn snap_overrides_submenu_lists_every_object_snap() {
let ctx = MenuContext::Command {
options: Vec::new(),
has_point_step: true,
recent_inputs: Vec::new(),
};
let menu = build_context_menu(&ctx, Some(SubmenuId::SnapOverrides));
let acts = actions(&menu);
assert!(acts.contains(&MenuAction::Mtp));
for &(t, _, _) in crate::snap::ALL_SNAP_MODES {
assert!(acts.contains(&MenuAction::SnapOverride(t)), "missing {t:?}");
}
assert!(acts.contains(&MenuAction::SnapOverrideNone));
assert!(acts.contains(&MenuAction::Command("OSNAP".into())));
// An entity-pick step offers no snap overrides.
let ctx = MenuContext::Command {
options: Vec::new(),
has_point_step: false,
recent_inputs: Vec::new(),
};
let menu = build_context_menu(&ctx, None);
assert!(!actions(&menu).contains(&MenuAction::ToggleSubmenu(SubmenuId::SnapOverrides)));
}
fn idle_ctx(has_selection: bool) -> MenuContext {
MenuContext::Idle {
has_selection,
selected_constraint: None,
isolation_active: false,
recent_cmds: vec!["LINE".into(), "CIRCLE".into()],
clipboard_nonempty: false,
undo_label: Some("LINE".into()),
redo_label: None,
props_open: false,
}
}
#[test]
fn idle_menu_without_selection_has_repeat_default() {
let menu = build_context_menu(&idle_ctx(false), None);
let first = item_at(&menu, 0);
assert_eq!(first.action, MenuAction::Command("LINE".into()));
assert!(first.default);
assert_eq!(menu.default_index(), 0);
let acts = actions(&menu);
assert!(acts.contains(&MenuAction::Command("PAN".into())));
assert!(acts.contains(&MenuAction::Options));
assert!(!acts.contains(&MenuAction::DeleteSelected));
assert!(!acts.contains(&MenuAction::DeselectAll));
}
#[test]
fn selection_menu_lists_modify_actions() {
let menu = build_context_menu(&idle_ctx(true), Some(SubmenuId::DrawOrder));
let acts = actions(&menu);
for want in [
MenuAction::DeleteSelected,
MenuAction::Command("MOVE".into()),
MenuAction::Command("COPY".into()),
MenuAction::Command("SCALE".into()),
MenuAction::Command("ROTATE".into()),
MenuAction::Command("MIRROR".into()),
MenuAction::ToggleSubmenu(SubmenuId::DrawOrder),
MenuAction::Command("DRAWORDER F".into()),
MenuAction::DrawOrderPickRef(false),
MenuAction::SelectSimilar,
MenuAction::DeselectAll,
] {
assert!(acts.contains(&want), "missing {want:?} in {acts:?}");
}
}
#[test]
fn clipboard_rows_disabled_when_empty() {
let menu = build_context_menu(&idle_ctx(false), Some(SubmenuId::Clipboard));
let sel = menu.selectable();
let paste = sel
.iter()
.find(|s| s.action == MenuAction::Command("PASTECLIP".into()))
.expect("paste row");
assert!(!paste.enabled);
let cut = sel
.iter()
.find(|s| s.action == MenuAction::Command("CUTCLIP".into()))
.expect("cut row");
assert!(!cut.enabled);
}
#[test]
fn undo_row_uses_history_label() {
let menu = build_context_menu(&idle_ctx(false), None);
let rows = labels(&menu);
let undo = t!("Undo %{label}", label = "LINE").into_owned();
assert!(rows.contains(&undo), "{rows:?}");
let sel = menu.selectable();
let redo = sel.iter().find(|s| s.action == MenuAction::Redo).expect("redo row");
assert!(!redo.enabled);
let undo = sel.iter().find(|s| s.action == MenuAction::Undo).expect("undo row");
assert!(undo.enabled);
}
#[test]
fn selectable_skips_separators_and_closed_submenus() {
let menu = build_context_menu(&idle_ctx(false), None);
let sel = menu.selectable();
assert!(sel.iter().all(|s| s.action != MenuAction::Command("PASTECLIP".into())));
assert!(sel.iter().any(|s| s.header == Some(SubmenuId::Clipboard)));
let open = build_context_menu(&idle_ctx(false), Some(SubmenuId::Clipboard));
assert!(open
.selectable()
.iter()
.any(|s| s.action == MenuAction::Command("PASTECLIP".into())));
}
#[test]
fn find_mnemonic_cycles_duplicates() {
let ctx = MenuContext::Command {
options: vec![
CmdOption::new("Angle", "A"),
CmdOption::new("CEnter", "CE"),
CmdOption::new("CLose", "CL"),
],
has_point_step: false,
recent_inputs: Vec::new(),
};
let menu = build_context_menu(&ctx, None);
// rows: Enter(0) Cancel(1) Angle(2) CEnter(3) CLose(4) …
assert_eq!(menu.find_mnemonic('c', None), Some(3));
assert_eq!(menu.find_mnemonic('C', Some(3)), Some(4));
assert_eq!(menu.find_mnemonic('C', Some(4)), Some(3));
assert_eq!(menu.find_mnemonic('Z', None), None);
}
#[test]
fn default_row_y_accounts_for_separators() {
let menu = build_context_menu(&line_ctx(), None);
assert_eq!(menu.default_row_y(), MENU_PAD_TOP);
let menu = build_context_menu(&idle_ctx(false), None);
assert_eq!(menu.default_row_y(), MENU_PAD_TOP);
// 11 items + 3 submenu headers + 6 separators, plus panel padding.
let expected = MENU_PAD_TOP * 2.0 + 14.0 * MENU_ROW_H + 6.0 * MENU_SEP_H;
assert_eq!(menu.height(), expected);
}
#[test]
fn grip_menu_marks_current_mode() {
let menu = build_context_menu(
&MenuContext::Grip(GripMenuContext {
mode: GripEditMode::Stretch,
copy_on: true,
moved: false,
}),
None,
);
assert_eq!(item_at(&menu, 0).action, MenuAction::Enter);
assert!(item_at(&menu, 2).checked);
let sel = menu.selectable();
let stretch = sel
.iter()
.position(|s| s.action == MenuAction::Grip(GripMenuCmd::Stretch))
.unwrap();
assert_eq!(stretch, 1);
let undo = sel
.iter()
.find(|s| s.action == MenuAction::Grip(GripMenuCmd::Undo))
.unwrap();
assert!(!undo.enabled);
}
}