forked from 1b-agent/opencadstudio-upstream
feat(ui): feature-parity context menu — Snap Overrides, transparent Pan/Zoom, ARC keyword flow
The command menu now follows the commercial solutions' layout row for row: Enter, Cancel,
Recent Input ▸, the command's keywords, Snap Overrides ▸ (every object snap
with its glyph, Mid Between 2 Points, None, Osnap Settings…), then Pan and
Zoom. Pan / Zoom run transparently while a command is active ('PAN,
'ZOOM …): navigation modes leave the command untouched and an interactive
ZOOM Window parks it and resumes it afterwards. Rows carry an icon gutter
so labels align; the Enter row shows a default value ("<0.5000>") as its
hint instead of a second finish row.
ARC gets the commercial solutions' prompt flow with all keywords: start [Center], second
point [Center/End], end; after Center: end [Angle/chord Length]; after
End: center [Angle/Direction/Radius]; Enter continues tangent. The
previous default (Center/Start/End with method codes) stays as ARC_CSE.
Object snaps gain a one-shot "None" override. New strings in all locales.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
96df4928c7
commit
02cd98e790
34 files changed
+1305
-67
No files matched your search
@@ -920,6 +920,9 @@ impl OpenCADStudio {
|
||||
);
|
||||
let task = self.apply_cmd_result_inner(result);
|
||||
let i = self.active_tab;
|
||||
// A transparent zoom that just finished hands control back to the
|
||||
// command it interrupted before the "command ended" bookkeeping below.
|
||||
self.resume_transparent_parent(i);
|
||||
let preview_hidden = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
@@ -1394,6 +1397,38 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// Bring back the command a transparent zoom parked, once the zoom is
|
||||
/// over (no command active any more). No-op otherwise.
|
||||
pub(in crate::app) fn resume_transparent_parent(&mut self, i: usize) {
|
||||
if !self.tabs[i].transparent_resume {
|
||||
return;
|
||||
}
|
||||
if self.tabs[i].active_cmd.is_some() {
|
||||
// The transparent prompt is still up (or the parent was already
|
||||
// restored by a Cancel).
|
||||
if self.tabs[i].suspended_cmd.is_none() {
|
||||
self.tabs[i].transparent_resume = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.tabs[i].transparent_resume = false;
|
||||
let Some(parent) = self.tabs[i].suspended_cmd.take() else {
|
||||
return;
|
||||
};
|
||||
self.tabs[i].active_cmd = Some(parent);
|
||||
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
|
||||
if let Some(p) = prompt {
|
||||
self.command_line.push_info(&p);
|
||||
}
|
||||
let opts = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.options())
|
||||
.unwrap_or_default();
|
||||
self.command_line.set_step_options(opts);
|
||||
self.refresh_active_cmd_preview(i);
|
||||
}
|
||||
|
||||
pub(in crate::app) fn start_mtp_modifier(&mut self, i: usize) {
|
||||
let parent = self.tabs[i].active_cmd.take();
|
||||
self.tabs[i].suspended_cmd = parent;
|
||||
|
||||
@@ -311,8 +311,8 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
"ARC" => {
|
||||
use crate::modules::draw::draw::arc::Arc3PCommand;
|
||||
let new_cmd = Arc3PCommand::new();
|
||||
use crate::modules::draw::draw::arc::ArcCommand;
|
||||
let new_cmd = ArcCommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
@@ -323,8 +323,8 @@ impl OpenCADStudio {
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
"ARC_CSE" => {
|
||||
use crate::modules::draw::draw::arc::ArcCommand;
|
||||
let new_cmd = ArcCommand::new();
|
||||
use crate::modules::draw::draw::arc::ArcCSECommand;
|
||||
let new_cmd = ArcCSECommand::new();
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
||||
+40
-5
@@ -63,6 +63,12 @@ impl OpenCADStudio {
|
||||
// routing, so every path below (Start-tab gate, plugins, all dispatch
|
||||
// families, the Repeat menu) sees the canonical command. Arguments after
|
||||
// the first space are left untouched. A non-alias passes through as-is.
|
||||
// AutoCAD's transparent prefix: `'PAN` / `'ZOOM` run in the middle
|
||||
// of another command and hand control back to it afterwards.
|
||||
let (cmd, quoted) = match cmd.trim().strip_prefix('\'') {
|
||||
Some(rest) => (rest.trim(), true),
|
||||
None => (cmd, false),
|
||||
};
|
||||
let resolved = self.resolve_alias(cmd);
|
||||
let cmd = resolved.as_deref().unwrap_or(cmd);
|
||||
if is_spacemouse_command(cmd) {
|
||||
@@ -72,11 +78,22 @@ impl OpenCADStudio {
|
||||
// already running: pressing F8 partway through a LINE means "constrain
|
||||
// the rest of this line", not "abandon it". Everything below tears the
|
||||
// running command down, so a transparent one skips straight past it.
|
||||
// (#677)
|
||||
if is_transparent(cmd) {
|
||||
return self
|
||||
.dispatch_families(cmd, i)
|
||||
.unwrap_or_else(Task::none);
|
||||
// (#677) A quoted navigation command does the same; an interactive
|
||||
// one (ZOOM Window) parks the running command and resumes it when the
|
||||
// zoom ends, as does a ZOOM the zoom prompt itself hands off to.
|
||||
let transparent = is_transparent(cmd)
|
||||
|| (quoted && is_transparent_capable(cmd))
|
||||
|| (self.tabs[i].transparent_resume && cmd.starts_with("ZOOM"));
|
||||
if transparent {
|
||||
if zoom_prompts(cmd) && self.tabs[i].active_cmd.is_some() {
|
||||
self.tabs[i].suspended_cmd = self.tabs[i].active_cmd.take();
|
||||
self.tabs[i].transparent_resume = true;
|
||||
self.tabs[i].scene.clear_preview_wire();
|
||||
}
|
||||
let task = self.dispatch_families(cmd, i).unwrap_or_else(Task::none);
|
||||
// A one-shot zoom is over already — resume straight away.
|
||||
self.resume_transparent_parent(i);
|
||||
return task;
|
||||
}
|
||||
// A new command abandons any grip edit and its numeric input.
|
||||
let had_pending_grip_input = self.grip_pending.take().is_some();
|
||||
@@ -115,6 +132,9 @@ impl OpenCADStudio {
|
||||
// template-property override too (#239).
|
||||
self.restore_add_selected_defaults();
|
||||
}
|
||||
// A command parked behind a transparent zoom / MTP goes with it.
|
||||
self.tabs[i].suspended_cmd = None;
|
||||
self.tabs[i].transparent_resume = false;
|
||||
// Starting any command leaves interactive navigation modes (their own
|
||||
// command arms below re-enable the selected one).
|
||||
self.tabs[i].pan_mode = false;
|
||||
@@ -275,6 +295,21 @@ impl OpenCADStudio {
|
||||
/// point, so F8 during a MOVE both ended the move and made the dragged ghost
|
||||
/// vanish. Nothing here starts a command, opens a document or reads geometry,
|
||||
/// so there is nothing for the teardown to protect. (#677)
|
||||
/// Commands that may run transparently (`'PAN`, `'ZOOM …`): the navigation
|
||||
/// set, which never edits the drawing.
|
||||
fn is_transparent_capable(cmd: &str) -> bool {
|
||||
cmd == "PAN" || cmd == "ZOOM" || cmd.starts_with("ZOOM ") || matches!(cmd, "ZW" | "ZE" | "ZA" | "ZP" | "ZI" | "ZO" | "ZD" | "ZEA" | "ZOBJ")
|
||||
}
|
||||
|
||||
/// ZOOM forms that install an interactive prompt (window corners, object
|
||||
/// pick) and so need the running command parked while they last.
|
||||
fn zoom_prompts(cmd: &str) -> bool {
|
||||
matches!(
|
||||
cmd,
|
||||
"ZOOM" | "ZOOM WINDOW" | "ZOOM W" | "ZW" | "ZOOM OBJECT" | "ZOOM O" | "ZOBJ"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_transparent(cmd: &str) -> bool {
|
||||
is_spacemouse_command(cmd)
|
||||
|| matches!(
|
||||
|
||||
@@ -231,6 +231,9 @@ pub(super) struct DocumentTab {
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub(super) plugin_state: HashMap<&'static str, Box<dyn Any + Send + Sync>>,
|
||||
pub(super) suspended_cmd: Option<Box<dyn CadCommand>>,
|
||||
/// `suspended_cmd` was parked by a transparent command (`'ZOOM`) and is
|
||||
/// restored as soon as the transparent one ends.
|
||||
pub(super) transparent_resume: bool,
|
||||
}
|
||||
|
||||
impl DocumentTab {
|
||||
@@ -642,6 +645,7 @@ impl DocumentTab {
|
||||
zoom_dynamic_mode: false,
|
||||
plugin_state: HashMap::new(),
|
||||
suspended_cmd: None,
|
||||
transparent_resume: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2033,6 +2033,8 @@ pub enum Message {
|
||||
/// Mid Between 2 Points from the snap menu: modal 2-pick modifier over
|
||||
/// the active point prompt.
|
||||
SnapOverrideMtp,
|
||||
/// Snap Overrides ▸ None: the next pick ignores object snaps.
|
||||
SnapOverrideNone,
|
||||
/// Close the one-shot snap override menu without picking.
|
||||
SnapOverrideClose,
|
||||
/// Open a path from the Start tab's recent-documents list (skips the
|
||||
|
||||
@@ -140,6 +140,8 @@ impl OpenCADStudio {
|
||||
}
|
||||
MenuAction::Command(cmd) => self.update(Message::Command(cmd)),
|
||||
MenuAction::Mtp => self.update(Message::SnapOverrideMtp),
|
||||
MenuAction::SnapOverride(t) => self.update(Message::SnapOverridePick(t)),
|
||||
MenuAction::SnapOverrideNone => self.update(Message::SnapOverrideNone),
|
||||
MenuAction::DeleteSelected => self.update(Message::DeleteSelected),
|
||||
MenuAction::SelectSimilar => self.update(Message::SelectSimilar),
|
||||
MenuAction::InvertSelection => self.update(Message::InvertSelection),
|
||||
@@ -455,7 +457,8 @@ mod tests {
|
||||
assert_eq!(acts[1], MenuAction::Cancel);
|
||||
assert!(acts.contains(&MenuAction::Option("C".into())));
|
||||
assert!(acts.contains(&MenuAction::Option("U".into())));
|
||||
assert!(acts.contains(&MenuAction::Mtp));
|
||||
assert!(acts.contains(&MenuAction::ToggleSubmenu(SubmenuId::SnapOverrides)));
|
||||
assert!(acts.contains(&MenuAction::Command("'PAN".into())));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -831,3 +834,110 @@ mod tests {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod transparent_tests {
|
||||
use super::*;
|
||||
use crate::command::StepInput;
|
||||
use glam::DVec3;
|
||||
|
||||
fn with_stack(f: impl FnOnce() + Send + 'static) {
|
||||
std::thread::Builder::new()
|
||||
.stack_size(32 * 1024 * 1024)
|
||||
.spawn(f)
|
||||
.expect("spawn test thread")
|
||||
.join()
|
||||
.unwrap_or_else(|e| std::panic::resume_unwind(e));
|
||||
}
|
||||
|
||||
fn line_app() -> OpenCADStudio {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
app.tabs[0].scene.selection.borrow_mut().vp_size = (800.0, 600.0);
|
||||
let _ = app.dispatch_command("LINE");
|
||||
let _ = app.feed_command(StepInput::Point(DVec3::ZERO));
|
||||
app
|
||||
}
|
||||
|
||||
fn active(app: &OpenCADStudio) -> Option<&'static str> {
|
||||
app.tabs[0].active_cmd.as_ref().map(|c| c.name())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_pan_keeps_the_command_running() {
|
||||
with_stack(|| {
|
||||
let mut app = line_app();
|
||||
let _ = app.update(Message::ContextMenuPick(MenuAction::Command("'PAN".into())));
|
||||
assert!(app.tabs[0].pan_mode);
|
||||
assert_eq!(active(&app), Some("LINE"));
|
||||
// Esc leaves pan mode; the line is still being drawn.
|
||||
let _ = app.update(Message::CommandEscape);
|
||||
assert!(!app.tabs[0].pan_mode);
|
||||
assert_eq!(active(&app), Some("LINE"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_zoom_window_resumes_the_command() {
|
||||
with_stack(|| {
|
||||
let mut app = line_app();
|
||||
let _ = app.dispatch_command("'ZOOM W");
|
||||
assert_eq!(active(&app), Some("ZOOM WINDOW"));
|
||||
assert!(app.tabs[0].transparent_resume);
|
||||
let _ = app.feed_command(StepInput::Point(DVec3::new(-5.0, -5.0, 0.0)));
|
||||
let _ = app.feed_command(StepInput::Point(DVec3::new(5.0, 5.0, 0.0)));
|
||||
assert_eq!(active(&app), Some("LINE"), "LINE resumes after the zoom");
|
||||
assert!(!app.tabs[0].transparent_resume);
|
||||
assert!(app.tabs[0].suspended_cmd.is_none());
|
||||
// The line still has its first point: the next pick draws a segment.
|
||||
let _ = app.feed_command(StepInput::Point(DVec3::new(10.0, 0.0, 0.0)));
|
||||
let lines = app.tabs[0]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter(|e| matches!(e, acadrust::EntityType::Line(_)))
|
||||
.count();
|
||||
assert_eq!(lines, 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_prompt_handoff_and_escape_both_resume() {
|
||||
with_stack(|| {
|
||||
let mut app = line_app();
|
||||
let _ = app.dispatch_command("'ZOOM");
|
||||
let _ = app.feed_active_cmd("E"); // → ZOOM EXTENTS, one-shot
|
||||
assert_eq!(active(&app), Some("LINE"));
|
||||
let _ = app.dispatch_command("'ZOOM");
|
||||
assert_eq!(active(&app), Some("ZOOM WINDOW"));
|
||||
let _ = app.update(Message::CommandEscape);
|
||||
assert_eq!(active(&app), Some("LINE"));
|
||||
assert!(!app.tabs[0].transparent_resume);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_command_drops_a_parked_one() {
|
||||
with_stack(|| {
|
||||
let mut app = line_app();
|
||||
let _ = app.dispatch_command("'ZOOM");
|
||||
let _ = app.dispatch_command("CIRCLE");
|
||||
assert_eq!(active(&app), Some("CIRCLE"));
|
||||
assert!(app.tabs[0].suspended_cmd.is_none());
|
||||
let _ = app.update(Message::CommandEscape);
|
||||
assert_eq!(active(&app), None, "no stale LINE comes back");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_override_none_is_one_shot() {
|
||||
with_stack(|| {
|
||||
let mut app = line_app();
|
||||
app.snapper.snap_enabled = true;
|
||||
let _ = app.update(Message::ContextMenuPick(MenuAction::SnapOverrideNone));
|
||||
assert!(!app.snapper.snap_enabled);
|
||||
app.snapper.clear_override();
|
||||
assert!(app.snapper.snap_enabled);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -754,6 +754,14 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::SnapOverrideNone => {
|
||||
self.snap_override_popup = None;
|
||||
self.snapper.set_override_none();
|
||||
self.command_line
|
||||
.push_info(crate::t!("Snap override: None (next pick only).").as_ref());
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::SnapOverrideMtp => {
|
||||
self.snap_override_popup = None;
|
||||
self.tabs[self.active_tab]
|
||||
|
||||
+36
-4
@@ -11,7 +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};
|
||||
use crate::ui::popup::context_menu::{ContextMenu, MenuIcon, MenuItem, MenuRow, MENU_PAD_TOP, MENU_ROW_H};
|
||||
|
||||
pub(super) fn position_canvas_overlay<'a>(
|
||||
anchor: iced::Point,
|
||||
@@ -1123,6 +1123,29 @@ impl Widget<Message, Theme, iced::Renderer> for ClampedPin<'_> {
|
||||
/// 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;
|
||||
/// Width of the icon gutter every row reserves so labels line up whether or
|
||||
/// not the row carries a glyph (object snaps, Pan / Zoom).
|
||||
const MENU_GUTTER_W: f32 = 18.0;
|
||||
const MENU_ICON_SIZE: f32 = 14.0;
|
||||
|
||||
/// The gutter cell: the row's glyph, or empty space of the same width.
|
||||
fn context_menu_gutter(icon: Option<MenuIcon>) -> Element<'static, Message> {
|
||||
let bytes = icon.map(|icon| match icon {
|
||||
MenuIcon::Snap(t) => crate::ui::icons::osnap(t),
|
||||
MenuIcon::Mtp => crate::ui::icons::mtp_icon(),
|
||||
MenuIcon::Pan => crate::ui::icons::pan_icon(),
|
||||
MenuIcon::Zoom => crate::ui::icons::zoom_icon(),
|
||||
});
|
||||
let cell: Element<'static, Message> = match bytes {
|
||||
Some(bytes) => crate::ui::icons::themed::<Message>(bytes, MENU_ICON_SIZE),
|
||||
None => iced::widget::Space::new().width(MENU_ICON_SIZE).height(MENU_ICON_SIZE).into(),
|
||||
};
|
||||
container(cell)
|
||||
.width(Length::Fixed(MENU_GUTTER_W))
|
||||
.align_x(iced::Center)
|
||||
.align_y(iced::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Render the right-click context menu (rows from
|
||||
/// `ui::popup::context_menu::build_context_menu`). The panel is placed so the
|
||||
@@ -1175,14 +1198,21 @@ pub(super) fn viewport_context_menu_overlay(
|
||||
crate::ui::icons::themed_arrow_right(9.0)
|
||||
};
|
||||
let content = row![
|
||||
context_menu_gutter(None),
|
||||
text(label.clone()).size(12),
|
||||
iced::widget::Space::new().width(Fill),
|
||||
caret,
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Center);
|
||||
let enabled = !children.is_empty();
|
||||
let mut btn = button(content)
|
||||
.padding([3, 12])
|
||||
.padding(iced::Padding {
|
||||
top: 3.0,
|
||||
right: 12.0,
|
||||
bottom: 3.0,
|
||||
left: 6.0,
|
||||
})
|
||||
.width(Fill)
|
||||
.height(Length::Fixed(MENU_ROW_H))
|
||||
.style(move |theme: &Theme, status| context_menu_row_style(theme, status, is_hl));
|
||||
@@ -1241,7 +1271,9 @@ fn context_menu_row(item: &MenuItem, indent: f32, highlighted: bool) -> Element<
|
||||
});
|
||||
}
|
||||
let enabled = item.enabled;
|
||||
let mut content = row![label_text].spacing(8).align_y(iced::Center);
|
||||
let mut content = row![context_menu_gutter(item.icon), label_text]
|
||||
.spacing(4)
|
||||
.align_y(iced::Center);
|
||||
if let Some(hint) = item.hint.as_ref() {
|
||||
content = content
|
||||
.push(iced::widget::Space::new().width(Fill))
|
||||
@@ -1266,7 +1298,7 @@ fn context_menu_row(item: &MenuItem, indent: f32, highlighted: bool) -> Element<
|
||||
top: 3.0,
|
||||
right: 12.0,
|
||||
bottom: 3.0,
|
||||
left: 12.0 + indent,
|
||||
left: 6.0 + indent,
|
||||
})
|
||||
.width(Fill)
|
||||
.height(Length::Fixed(MENU_ROW_H))
|
||||
|
||||
Reference in new issue
Block a user