Copy and paste between drawings; PASTEORIG, PASTEBLOCK; right-click menu (part of #325) (#355)

Copying in one drawing and pasting in another lost what the objects
refer to: a line on a layer the target lacked landed on a new default
layer (colour and linetype gone), and a block reference pointed at a
block the target didn't have. Cmd/Ctrl+C, X and V did nothing on the
desktop, because egui delivers them as clipboard events, not key
presses. PASTEORIG had no checks, and the canvas had no right-click
menu.

- Engine (cmd/clipboard.rs): the session clipboard keeps, with the
  copied objects, the layers, linetypes, text/dimension/multileader/
  table styles and blocks (nested too) they use. Pasting adds the ones
  the target lacks; names it has keep the target's definitions, as in
  AutoCAD; generated (*U, *D) blocks whose name is taken get a fresh
  one; objects get new handles.
- PASTEORIG pastes at the original coordinates, only into a drawing
  other than the one copied from (greyed and refused there, as in
  AutoCAD). PASTECLIP and PASTEBLOCK are greyed with an empty clipboard.
- PASTEBLOCK (Edit > Paste as Block, Cmd+Shift+V): the clipboard
  becomes an A$C… block inserted at a point. AI_DESELECT.
- UI: Cut/Copy/Paste events run the clipboard commands (Shift: Copy
  with Base Point / Paste as Block). Each copy is also put on the system
  clipboard as DXF text, and pasted DXF text that isn't our own last
  copy becomes the clipboard, so another CADCraft window can paste it.
- Right-click on the canvas with no command running opens a shortcut
  menu: Repeat <last>, Clipboard (Cut, Copy, Copy with Base Point,
  Paste, Paste as Block, Paste to Original Coordinates), the
  selection's editing commands and Deselect All, Undo/Redo, Pan/Zoom,
  Quick Select, Find. During a command it is still Enter.
- Ukrainian labels for the new items.

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-10 17:32:34 -04:00
1 parent 4010b94919
commit 93152afa8e
14 files changed
+891 -40

No files matched your search

+355
View File
@@ -0,0 +1,355 @@
//! The clipboard every open drawing shares (COPYCLIP, CUTCLIP, COPYBASE; PASTECLIP, PASTEORIG,
//! PASTEBLOCK).
//!
//! Like AutoCAD's, it holds the copied objects together with what they refer to in their drawing:
//! layers, linetypes, text, dimension, multileader and table styles, and the blocks they reference
//! (nested ones too). Pasting adds the ones the target drawing doesn't have; a name the target
//! already has keeps the target's definition, and a generated (anonymous `*…`) block whose name is
//! taken gets a fresh one. Pasted objects get new handles.
//!
//! The clipboard can also travel as DXF text ([`system_text`], [`load_system_text`]), so a front
//! end can put it on the system clipboard and paste it into another CADCraft window.
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::sync::Arc;
use cadcraft_doc::{Block, Drawing, Entity, EntityKind, HVal, Handle};
use cadcraft_geom::{Mat3, Vec2};
use super::{bad, has_doc};
use crate::{Result, Session};
/// More objects than this aren't mirrored to (or read from) the system clipboard.
const MAX_SYSTEM_OBJECTS: usize = 50_000;
/// Bounds the walk through nested block references.
const MAX_WALK: usize = 5_000_000;
/// Where the clipboard's objects came from, and the definitions they need.
#[derive(Clone, Debug)]
pub struct ClipSource {
/// `DocState::uid` of the drawing they were copied from; `None` when they came from the system
/// clipboard.
pub uid: Option<u64>,
/// The layers, linetypes, styles and blocks the objects reference (and the source's header).
pub defs: Arc<Drawing>,
/// Counts copies, so a front end mirrors each new clipboard to the system clipboard once.
pub serial: u64,
}
/// Put copies of `ents` from the active drawing on the clipboard, with `base` as base point.
pub(crate) fn copy(s: &mut Session, ents: Vec<Entity>, base: Vec2) -> Result<()> {
let uid = s.state()?.uid;
let defs = defs_for(s.doc()?, &ents);
set(s, ents, base, defs, Some(uid));
Ok(())
}
fn set(s: &mut Session, ents: Vec<Entity>, base: Vec2, defs: Drawing, uid: Option<u64>) {
let serial = s.clipboard_source.as_ref().map_or(0, |c| c.serial).wrapping_add(1);
s.clipboard = ents;
s.clipboard_base = base;
s.clipboard_source = Some(ClipSource { uid, defs: Arc::new(defs), serial });
}
/// Enabled check of PASTECLIP and PASTEBLOCK: something to paste.
pub(crate) fn has_clip(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.clipboard.is_empty() { Err("the clipboard is empty".into()) } else { Ok(()) }
}
/// Enabled check of PASTEORIG: as in AutoCAD, it pastes objects copied from another drawing.
pub(crate) fn can_paste_orig(s: &Session) -> std::result::Result<(), String> {
has_clip(s)?;
if from_active(s) {
return Err("the clipboard holds objects from this drawing; Paste to Original Coordinates pastes into another drawing".into());
}
Ok(())
}
/// Whether the clipboard was copied from the active drawing.
pub fn from_active(s: &Session) -> bool {
let active = s.state().ok().map(|st| st.uid);
s.clipboard_source.as_ref().is_some_and(|c| c.uid.is_some() && c.uid == active)
}
/// Paste the clipboard into the active drawing's current space, moved by `m`; selects and
/// returns the new objects.
pub(crate) fn paste(s: &mut Session, m: &Mat3) -> Result<Vec<Handle>> {
let ents = s.clipboard.clone();
let defs = s.clipboard_source.as_ref().map(|c| c.defs.clone());
let space = s.space();
let d = s.doc_mut()?;
let renames = defs.map(|x| merge(d, &x)).unwrap_or_default();
let mut out = Vec::new();
for e in &ents {
let mut e = adopt(d, e, &renames);
e.kind.transform(m);
d.ensure_layer(&e.common.layer);
out.push(e.handle);
if let Some(st) = d.space_mut(&space) {
st.push(e);
}
}
s.set_selection(out.clone());
Ok(out)
}
/// PASTEBLOCK: the clipboard becomes a new block (named `A$C…`, base point = the clipboard's), and
/// a reference to it is inserted at `at`. Returns the block reference.
pub(crate) fn paste_block(s: &mut Session, at: Vec2) -> Result<Handle> {
let ents = s.clipboard.clone();
if ents.is_empty() {
return Err(bad("pasteblock", "the clipboard is empty"));
}
let defs = s.clipboard_source.as_ref().map(|c| c.defs.clone());
let base = s.clipboard_base;
let d = s.doc_mut()?;
let renames = defs.map(|x| merge(d, &x)).unwrap_or_default();
let seed = d.handseed;
let name = (0..1_000_000u64)
.map(|i| format!("A$C{:08X}", seed.wrapping_add(i) & 0xFFFF_FFFF))
.find(|n| d.block(n).is_none())
.ok_or_else(|| bad("pasteblock", "no free block name"))?;
let mut b = Block::new(&name);
b.base = base.to3(0.0);
for e in &ents {
let e = adopt(d, e, &renames);
d.ensure_layer(&e.common.layer);
b.entities.push(e);
}
d.blocks.insert(name.clone(), Arc::new(b));
let h = super::blocks::insert(s, &name, at, 1.0, 0.0, &serde_json::Map::new())?;
s.set_selection(vec![h]);
Ok(h)
}
/// The clipboard as DXF text (base point in `$INSBASE`), for the system clipboard. `None` when
/// it is empty, very large, or this build has no DXF writer.
pub fn system_text(s: &Session) -> Option<String> {
if s.clipboard.is_empty() || s.clipboard.len() > MAX_SYSTEM_OBJECTS {
return None;
}
let hooks = super::file::io()?;
let mut d = Drawing::new_imperial();
let renames = match &s.clipboard_source {
Some(c) => {
d.header = c.defs.header.clone();
merge(&mut d, &c.defs)
}
None => BTreeMap::new(),
};
d.header.set("INSBASE", HVal::Point(s.clipboard_base.to3(0.0)));
for e in &s.clipboard {
let e = adopt(&mut d, e, &renames);
d.ensure_layer(&e.common.layer);
d.model.push(e);
}
let bytes = (hooks.write)(&d, "clipboard.dxf").ok()?;
String::from_utf8(bytes).ok()
}
/// Make DXF text (from the system clipboard) the clipboard: its model space objects with what
/// they reference, the base point from `$INSBASE`. `false` (clipboard unchanged) when the text
/// isn't a DXF drawing with objects.
pub fn load_system_text(s: &mut Session, text: &str) -> bool {
let mut lines = text.lines().map(str::trim);
if lines.next() != Some("0") || lines.next() != Some("SECTION") || text.len() > 256 << 20 {
return false;
}
let Some(hooks) = super::file::io() else { return false };
let Ok(src) = (hooks.read)(text.as_bytes(), "clipboard.dxf") else { return false };
if src.model.is_empty() || src.model.len() > MAX_SYSTEM_OBJECTS {
return false;
}
let ents: Vec<Entity> = src.model.iter().map(|e| (**e).clone()).collect();
let base = src.header.point("INSBASE").map(|p| p.xy()).filter(|p| p.is_finite()).unwrap_or(Vec2::ZERO);
let defs = defs_for(&src, &ents);
set(s, ents, base, defs, None);
true
}
/// Upper-case names of the table records and blocks some objects use.
#[derive(Default)]
struct Used {
layers: HashSet<String>,
linetypes: HashSet<String>,
text_styles: HashSet<String>,
dim_styles: HashSet<String>,
mleader_styles: HashSet<String>,
table_styles: HashSet<String>,
blocks: HashSet<String>,
}
fn up(n: &str) -> String {
n.to_ascii_uppercase()
}
impl Used {
/// Note what `e` uses; returns the blocks it references.
fn note(&mut self, e: &Entity) -> Vec<String> {
self.layers.insert(up(&e.common.layer));
self.linetypes.insert(up(&e.common.linetype));
let mut blocks = Vec::new();
match &e.kind {
EntityKind::Text(t) => {
self.text_styles.insert(up(&t.style));
}
EntityKind::MText(t) => {
self.text_styles.insert(up(&t.style));
}
EntityKind::AttDef(a) => {
self.text_styles.insert(up(&a.text.style));
}
EntityKind::Insert(i) => {
self.text_styles.extend(i.attribs.iter().map(|a| up(&a.text.style)));
blocks.push(i.block.clone());
}
EntityKind::Dimension(dm) => {
self.dim_styles.insert(up(&dm.style));
blocks.extend(dm.block.clone());
}
EntityKind::Leader(l) => {
self.dim_styles.insert(up(&l.style));
}
EntityKind::MLeader(m) => {
self.mleader_styles.insert(up(&m.style));
self.text_styles.extend(m.text.iter().map(|t| up(&t.style)));
}
EntityKind::Table(t) => {
self.table_styles.insert(up(&t.style));
}
_ => {}
}
blocks
}
}
/// The definitions `ents` need from `src`: the layers, linetypes, styles and blocks they (and the
/// blocks they reference, nested ones too) use, and `src`'s header.
fn defs_for(src: &Drawing, ents: &[Entity]) -> Drawing {
let mut used = Used::default();
let mut frontier: Vec<String> = ents.iter().flat_map(|e| used.note(e)).collect();
let mut walked = 0usize;
while let Some(name) = frontier.pop() {
if !used.blocks.insert(up(&name)) {
continue;
}
let Some(b) = src.block(&name) else { continue };
for e in b.entities.iter() {
walked += 1;
if walked > MAX_WALK {
break;
}
frontier.extend(used.note(e));
}
}
// Records referenced by other records.
for l in src.layers.iter().filter(|l| used.layers.contains(&up(&l.name))) {
used.linetypes.insert(up(&l.linetype));
}
for t in src.linetypes.iter().filter(|t| used.linetypes.contains(&up(&t.name))) {
used.text_styles.extend(t.pattern.iter().filter_map(|p| p.style.as_deref()).map(up));
}
for ds in src.dim_styles.iter().filter(|t| used.dim_styles.contains(&up(&t.name))) {
used.text_styles.insert(up(&ds.text_style));
}
for ms in src.mleader_styles.iter().filter(|t| used.mleader_styles.contains(&up(&t.name))) {
used.text_styles.insert(up(&ms.text_style));
}
fn pick<T: Clone>(all: &[T], used: &HashSet<String>, name: fn(&T) -> &str) -> Vec<T> {
all.iter().filter(|t| used.contains(&up(name(t)))).cloned().collect()
}
let mut d = Drawing::new_imperial();
d.header = src.header.clone();
d.layers = pick(&src.layers, &used.layers, |l| &l.name);
d.linetypes = pick(&src.linetypes, &used.linetypes, |l| &l.name);
d.text_styles = pick(&src.text_styles, &used.text_styles, |t| &t.name);
d.dim_styles = pick(&src.dim_styles, &used.dim_styles, |t| &t.name);
d.mleader_styles = pick(&src.mleader_styles, &used.mleader_styles, |t| &t.name);
d.table_styles = pick(&src.table_styles, &used.table_styles, |t| &t.name);
d.blocks = src.blocks.iter().filter(|(k, _)| used.blocks.contains(&up(k))).map(|(k, b)| (k.clone(), b.clone())).collect();
d.layouts.clear();
d
}
/// Add `defs`' table records and blocks that `d` doesn't have. Returns the blocks copied, from
/// their name in `defs` to their name in `d`.
fn merge(d: &mut Drawing, defs: &Drawing) -> BTreeMap<String, String> {
fn add<T: Clone>(ours: &mut Vec<T>, theirs: &[T], name: fn(&T) -> &str) {
for t in theirs {
if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) {
ours.push(t.clone());
}
}
}
add(&mut d.layers, &defs.layers, |l| &l.name);
add(&mut d.linetypes, &defs.linetypes, |l| &l.name);
add(&mut d.text_styles, &defs.text_styles, |t| &t.name);
add(&mut d.dim_styles, &defs.dim_styles, |t| &t.name);
add(&mut d.mleader_styles, &defs.mleader_styles, |t| &t.name);
add(&mut d.table_styles, &defs.table_styles, |t| &t.name);
let renames = block_names(d, defs);
for (from, to) in &renames {
let Some(b) = defs.blocks.get(from) else { continue };
let mut nb = (**b).clone();
nb.name = to.clone();
nb.entities = Default::default();
for e in b.entities.iter() {
let e = adopt(d, e, &renames);
d.ensure_layer(&e.common.layer);
nb.entities.push(e);
}
d.blocks.insert(to.clone(), Arc::new(nb));
}
renames
}
/// Which blocks of `defs` to copy, and under which name: a new name keeps its own, a generated
/// (anonymous `*…`) block whose name is taken gets a fresh generated name, and a named block `d`
/// already has keeps `d`'s definition (not copied).
fn block_names(d: &Drawing, defs: &Drawing) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let mut taken: BTreeSet<String> = d.blocks.keys().map(|k| up(k)).collect();
for (name, b) in &defs.blocks {
let upper = up(name);
if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") {
continue;
}
let to = if !taken.contains(&upper) {
name.clone()
} else if b.anonymous || name.starts_with('*') {
let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect();
match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&up(n))) {
Some(n) => n,
None => continue,
}
} else {
continue;
};
taken.insert(up(&to));
out.insert(name.clone(), to);
}
out
}
/// A copy of a clipboard object for `d`: a fresh handle, block references renamed, and links to
/// objects of the source drawing (associative dimension points) dropped.
fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap<String, String>) -> Entity {
let mut e = e.clone();
e.handle = d.new_handle();
match &mut e.kind {
EntityKind::Insert(i) => {
if let Some(n) = renames.get(&i.block) {
i.block = n.clone();
}
}
EntityKind::Dimension(dm) => {
dm.assoc.clear();
if let Some(n) = dm.block.as_ref().and_then(|b| renames.get(b)) {
dm.block = Some(n.clone());
}
}
_ => {}
}
e
}
+57 -25
View File
@@ -34,11 +34,23 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("pasteclip", "Paste", run_paste)
.menu(&["Edit", "Paste"])
.key("Cmd+V")
.params("{at?: [x,y]} (default: same offset as copied)")
.interactive(|_| Ok(Box::new(PasteM { orig: false }))),
CommandSpec::new("pasteorig", "Paste to Original Coordinates", run_pasteorig).menu(&["Edit", "Paste to Original Coordinates"]).params("{}"),
.params("{at?: [x,y]} (where the base point lands; default: the original coordinates)")
.enabled(clipboard::has_clip)
.interactive(|_| Ok(Box::new(PasteM { mode: PasteMode::Clip }))),
CommandSpec::new("pasteblock", "Paste as Block", run_pasteblock)
.menu(&["Edit", "Paste as Block"])
.key("Cmd+Shift+V")
.params("{at?: [x,y]} (insertion point; default: the original coordinates)")
.enabled(clipboard::has_clip)
.interactive(|_| Ok(Box::new(PasteM { mode: PasteMode::Block }))),
CommandSpec::new("pasteorig", "Paste to Original Coordinates", run_pasteorig)
.menu(&["Edit", "Paste to Original Coordinates"])
.params("{} (only into a drawing other than the one copied from)")
.enabled(clipboard::can_paste_orig)
.interactive(|_| Ok(Box::new(PasteM { mode: PasteMode::Orig }))),
CommandSpec::new("erase.selection", "Clear", run_clear).menu(&["Edit", "Clear"]).key("Delete").enabled(has_selection).params("{}"),
CommandSpec::new("selectall", "Select All", run_selectall).menu(&["Edit", "Select All"]).key("Cmd+A").alias(&["ai_selall"]).noundo(),
CommandSpec::new("ai_deselect", "Deselect All", run_deselect).params("{}").noundo(),
CommandSpec::new("select", "Select", run_select)
.params("{handles: [hex]} | {window: [[x,y],[x,y]], crossing?: bool} | {at: [x,y]} | {clear: true} | {add?: bool}")
.noundo()
@@ -145,9 +157,9 @@ fn copy_to_clip(s: &mut Session, hs: &[Handle], base: Option<Vec2>) -> Result<us
let d = s.doc()?;
let ents: Vec<Entity> = hs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect();
let b = ents.iter().fold(Bounds2::EMPTY, |acc, e| acc.union(&entity_bounds(d, e, 0)));
s.clipboard_base = base.unwrap_or(b.min);
let base = base.unwrap_or(if b.min.is_finite() { b.min } else { Vec2::ZERO });
let n = ents.len();
s.clipboard = ents;
clipboard::copy(s, ents, base)?;
Ok(n)
}
@@ -176,23 +188,10 @@ fn run_cut(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "cut": n }))
}
pub(crate) fn paste(s: &mut Session, offset: Vec2) -> Result<Vec<Handle>> {
let clip = s.clipboard.clone();
let space = s.space();
let m = Mat3::translate(offset);
let d = s.doc_mut()?;
let mut out = Vec::new();
for mut e in clip {
e.handle = d.new_handle();
e.kind.transform(&m);
d.ensure_layer(&e.common.layer);
out.push(e.handle);
if let Some(st) = d.space_mut(&space) {
st.push(e);
}
}
s.set_selection(out.clone());
Ok(out)
/// Paste the clipboard moved by `offset` (the clipboard brings the layers, styles and blocks its
/// objects need).
fn paste(s: &mut Session, offset: Vec2) -> Result<Vec<Handle>> {
clipboard::paste(s, &Mat3::translate(offset))
}
fn run_paste(s: &mut Session, p: &Value) -> Result<Value> {
@@ -207,6 +206,16 @@ fn run_paste(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
fn run_pasteblock(s: &mut Session, p: &Value) -> Result<Value> {
let at = point_param(p, "at").unwrap_or(s.clipboard_base);
let h = clipboard::paste_block(s, at)?;
let block = match s.doc()?.entity(h).map(|e| &e.kind) {
Some(cadcraft_doc::EntityKind::Insert(i)) => i.block.clone(),
_ => String::new(),
};
Ok(json!({ "handle": h.hex(), "block": block }))
}
fn run_pasteorig(s: &mut Session, _p: &Value) -> Result<Value> {
let r = paste(s, Vec2::ZERO)?;
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
@@ -238,6 +247,11 @@ fn run_selectall(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({ "selected": n }))
}
fn run_deselect(s: &mut Session, _p: &Value) -> Result<Value> {
s.set_selection(Vec::new());
Ok(json!({ "selected": 0 }))
}
fn run_select(s: &mut Session, p: &Value) -> Result<Value> {
if bool_or(p, "clear", false) {
s.set_selection(Vec::new());
@@ -263,20 +277,34 @@ fn run_select(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "selected": sel.len(), "handles": sel.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
#[derive(Clone, Copy, PartialEq)]
enum PasteMode {
/// PASTECLIP: at an insertion point.
Clip,
/// PASTEBLOCK: as a block, at an insertion point.
Block,
/// PASTEORIG: at the original coordinates, without asking.
Orig,
}
struct PasteM {
orig: bool,
mode: PasteMode,
}
impl Interactive for PasteM {
fn name(&self) -> &'static str {
"PASTECLIP"
match self.mode {
PasteMode::Clip => "PASTECLIP",
PasteMode::Block => "PASTEBLOCK",
PasteMode::Orig => "PASTEORIG",
}
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
if s.clipboard.is_empty() {
s.echo("Clipboard is empty.");
return Ok(Step::Done);
}
if self.orig {
if self.mode == PasteMode::Orig {
paste(s, Vec2::ZERO)?;
return Ok(Step::Done);
}
@@ -287,6 +315,10 @@ impl Interactive for PasteM {
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match i {
Input::Point(p) if self.mode == PasteMode::Block => {
clipboard::paste_block(s, p)?;
Ok(Step::Done)
}
Input::Point(p) => {
let base = s.clipboard_base;
paste(s, p - base)?;
+1
View File
@@ -4,6 +4,7 @@
mod annotate;
mod array;
mod blocks;
pub mod clipboard;
pub mod constraints;
mod draw;
mod draw2;
+3
View File
@@ -350,6 +350,8 @@ pub struct Session {
pub last_dim: Option<Handle>,
/// Options remembered between invocations of interactive commands.
pub last_used: LastUsed,
/// Where `clipboard` came from and the layers, styles and blocks its objects need.
pub clipboard_source: Option<cmd::clipboard::ClipSource>,
}
impl Default for Session {
@@ -383,6 +385,7 @@ impl Session {
untitled_counter: 0,
last_dim: None,
last_used: LastUsed::default(),
clipboard_source: None,
}
}
pub fn new_drawing(&mut self, metric: bool) -> usize {
+5
View File
@@ -571,6 +571,11 @@ fn hostile_params_never_panic() {
fn every_interactive_command_starts_and_cancels() {
for c in command_specs().iter().filter(|c| c.interactive.is_some()) {
let mut s = Session::new();
if (c.enabled)(&s).is_err() {
// Unavailable in a new drawing (e.g. pasting with an empty clipboard): refused.
assert!(s.start(c.id).is_err() && s.running.is_none(), "{}", c.id);
continue;
}
s.start(c.id).unwrap();
let _ = s.prompt_text();
let _ = s.preview(Vec2::new(1.0, 1.0));
+214
View File
@@ -0,0 +1,214 @@
//! The clipboard between open drawings (#325): pasted objects bring the layers and blocks they
//! use, PASTEORIG pastes into another drawing at the same coordinates, and the clipboard can
//! travel as text through the system clipboard.
use std::sync::{Arc, Mutex};
use cadcraft_engine::Session;
use cadcraft_engine::cmd::clipboard;
use cadcraft_engine::cmd::file::{IoHooks, set_io};
use cadcraft_engine::doc::color::Color;
use cadcraft_engine::doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Insert, Layer, Line};
use cadcraft_engine::geom::{Vec2, Vec3};
use serde_json::json;
fn line(d: &mut Drawing, layer: &str, a: (f64, f64), b: (f64, f64)) -> Entity {
let kind = EntityKind::Line(Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) });
Entity { handle: d.new_handle(), common: Common { layer: layer.into(), ..Common::default() }, kind }
}
fn insert(d: &mut Drawing, block: &str, at: (f64, f64)) -> Entity {
let kind = EntityKind::Insert(Insert {
block: block.into(),
insert: Vec3::new(at.0, at.1, 0.0),
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: Vec::new(),
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
});
Entity { handle: d.new_handle(), common: Common::default(), kind }
}
fn block(d: &mut Drawing, name: &str, ents: Vec<Entity>) {
let mut b = Block::new(name);
b.anonymous = name.starts_with('*');
for e in ents {
b.entities.push(e);
}
d.blocks.insert(name.into(), Arc::new(b));
}
/// Drawing A: a line on WALLS and a DOOR block reference; DOOR holds a line on HINGE and a
/// reference to the generated block *U1.
fn drawing_a() -> Drawing {
let mut d = Drawing::new_metric();
d.layers.push(Layer { color: Color::Index(1), ..Layer::new("WALLS") });
d.layers.push(Layer { color: Color::Index(5), ..Layer::new("HINGE") });
let u = line(&mut d, "0", (0.0, 0.0), (1.0, 1.0));
block(&mut d, "*U1", vec![u]);
let leaf = line(&mut d, "HINGE", (0.0, 0.0), (0.0, 2.0));
let nested = insert(&mut d, "*U1", (0.0, 0.0));
block(&mut d, "DOOR", vec![leaf, nested]);
let wall = line(&mut d, "WALLS", (10.0, 20.0), (30.0, 20.0));
let door = insert(&mut d, "DOOR", (15.0, 20.0));
d.model.push(wall);
d.model.push(door);
d
}
/// Drawing B already has a WALLS layer (green) and a different *U1.
fn drawing_b() -> Drawing {
let mut d = Drawing::new_metric();
d.layers.push(Layer { color: Color::Index(3), ..Layer::new("WALLS") });
let other = line(&mut d, "0", (5.0, 5.0), (6.0, 6.0));
block(&mut d, "*U1", vec![other]);
d
}
fn handles(s: &Session) -> Vec<String> {
s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect()
}
fn model(s: &Session) -> Vec<Entity> {
s.doc().unwrap().model.iter().map(|e| (**e).clone()).collect()
}
#[test]
fn paste_into_another_drawing_brings_layers_and_blocks() {
let mut s = Session::empty();
let a = s.open_drawing(drawing_a(), "A", None);
let b = s.open_drawing(drawing_b(), "B", None);
s.active = a;
s.execute("copyclip", &json!({ "handles": handles(&s) })).unwrap();
assert_eq!(s.clipboard.len(), 2);
// The base point is the lower-left corner of the copied objects (the wall starts at 10,20).
assert_eq!(s.clipboard_base, Vec2::new(10.0, 20.0));
s.active = b;
let before: Vec<Handle> = s.doc().unwrap().model.iter().map(|e| e.handle).collect();
let r = s.execute("pasteclip", &json!({ "at": [110, 20] })).unwrap();
assert_eq!(r["handles"].as_array().map(Vec::len), Some(2));
let d = s.doc().unwrap();
// HINGE comes from A; WALLS keeps B's definition.
assert_eq!(d.layer("HINGE").map(|l| l.color), Some(Color::Index(5)));
assert_eq!(d.layer("WALLS").map(|l| l.color), Some(Color::Index(3)));
// DOOR comes along, its nested *U1 under a fresh name because B has a *U1 of its own.
let door = d.block("DOOR").expect("DOOR copied");
let nested: Vec<String> =
door.entities.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }).collect();
assert_eq!(nested, ["*U2"]);
assert_eq!(d.block("*U2").map(|b| b.entities.len()), Some(1));
let theirs = d.block("*U1").and_then(|b| b.entities.iter().next().cloned()).unwrap();
assert!(matches!(&theirs.kind, EntityKind::Line(l) if l.a.x == 5.0), "B's *U1 is unchanged");
// The objects moved by 100 in X, with new handles.
let pasted = model(&s);
assert_eq!(pasted.len(), 2);
assert!(pasted.iter().all(|e| !before.contains(&e.handle)));
assert!(matches!(&pasted[0].kind, EntityKind::Line(l) if l.a.x == 110.0 && l.a.y == 20.0));
assert_eq!(pasted[0].common.layer, "WALLS");
assert!(matches!(&pasted[1].kind, EntityKind::Insert(i) if i.block == "DOOR" && i.insert.x == 115.0));
assert_eq!(s.selection().len(), 2, "the pasted objects are selected");
// Undo removes the paste.
s.execute("u", &json!({})).unwrap();
assert!(s.doc().unwrap().model.is_empty());
}
#[test]
fn pasteorig_pastes_only_into_another_drawing() {
let mut s = Session::empty();
let a = s.open_drawing(drawing_a(), "A", None);
let b = s.open_drawing(drawing_b(), "B", None);
s.active = a;
s.execute("copyclip", &json!({ "handles": handles(&s) })).unwrap();
// In the drawing it came from, PASTEORIG is unavailable (greyed in menus).
assert!(clipboard::from_active(&s));
assert!(s.execute("pasteorig", &json!({})).is_err());
assert!(s.start("pasteorig").is_err());
assert_eq!(s.doc().unwrap().model.len(), 2);
let info = cadcraft_engine::find_command("pasteorig").unwrap().info(&s);
assert!(!info.enabled);
// In another drawing it pastes at the same coordinates without asking.
s.active = b;
s.start("pasteorig").unwrap();
assert!(s.running.is_none());
let pasted = model(&s);
assert!(matches!(&pasted[0].kind, EntityKind::Line(l) if l.a.x == 10.0 && l.b.x == 30.0 && l.a.y == 20.0));
assert!(s.doc().unwrap().block("DOOR").is_some());
// PASTECLIP asks for the insertion point and places the base point there.
s.start("pasteclip").unwrap();
assert!(s.prompt_text().contains("insertion point"), "{}", s.prompt_text());
s.input(cadcraft_engine::Input::Point(Vec2::new(0.0, 0.0))).unwrap();
assert!(s.running.is_none());
let pasted = model(&s);
assert_eq!(pasted.len(), 4);
assert!(matches!(&pasted[2].kind, EntityKind::Line(l) if l.a.x == 0.0 && l.a.y == 0.0));
}
#[test]
fn pasteblock_makes_a_block_of_the_clipboard() {
let mut s = Session::empty();
let a = s.open_drawing(drawing_a(), "A", None);
let b = s.open_drawing(drawing_b(), "B", None);
s.active = a;
s.execute("copyclip", &json!({ "handles": handles(&s) })).unwrap();
s.active = b;
let r = s.execute("pasteblock", &json!({ "at": [0, 0] })).unwrap();
let name = r["block"].as_str().unwrap().to_string();
assert!(name.starts_with("A$C"), "{name}");
let d = s.doc().unwrap();
assert_eq!(d.model.len(), 1);
let blk = d.block(&name).unwrap();
assert_eq!(blk.entities.len(), 2);
assert_eq!(blk.base, Vec3::new(10.0, 20.0, 0.0), "the clipboard's base point");
assert!(d.block("DOOR").is_some() && d.layer("HINGE").is_some());
}
/// A stand-in for the DXF writer and reader: "writes" the drawing to memory.
static WRITTEN: Mutex<Option<Drawing>> = Mutex::new(None);
#[test]
fn clipboard_travels_as_text() {
set_io(IoHooks {
read: |bytes, _| {
if bytes.starts_with(b"0\nSECTION") {
WRITTEN.lock().unwrap().clone().ok_or_else(|| "nothing written".into())
} else {
Err("not DXF".into())
}
},
write: |d, name| {
assert!(name.ends_with(".dxf"));
*WRITTEN.lock().unwrap() = Some(d.clone());
Ok(b"0\nSECTION\n2\nHEADER\n0\nENDSEC\n0\nEOF\n".to_vec())
},
plot: None,
});
let mut s = Session::empty();
let a = s.open_drawing(drawing_a(), "A", None);
s.execute("copybase", &json!({ "handles": handles(&s), "base": [10, 0] })).unwrap();
let text = clipboard::system_text(&s).expect("DXF text");
let written = WRITTEN.lock().unwrap().clone().unwrap();
assert_eq!(written.model.len(), 2);
assert!(written.block("DOOR").is_some() && written.layer("HINGE").is_some());
assert_eq!(written.header.point("INSBASE"), Some(Vec3::new(10.0, 0.0, 0.0)));
// Another CADCraft (here: a fresh session) pastes it, with its layers and blocks.
let mut t = Session::empty();
t.open_drawing(drawing_b(), "B", None);
assert!(!clipboard::load_system_text(&mut t, "just some words"));
assert!(t.clipboard.is_empty());
assert!(clipboard::load_system_text(&mut t, &text));
assert_eq!(t.clipboard.len(), 2);
assert_eq!(t.clipboard_base, Vec2::new(10.0, 0.0));
t.execute("pasteorig", &json!({})).unwrap();
assert_eq!(t.doc().unwrap().model.len(), 2);
assert!(t.doc().unwrap().block("DOOR").is_some() && t.doc().unwrap().layer("HINGE").is_some());
let _ = a;
}
+8 -9
View File
@@ -695,15 +695,14 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
}
}
}
if inside && pressed_secondary {
// Right-click acts as Enter while a command runs (the classic CAD default).
if app.session.running.is_some() {
let _ = app.session.input(Input::Enter);
} else if !app.session.selection().is_empty() {
app.session.set_selection(Vec::new());
} else if let Some(last) = app.session.last_command.clone() {
app.start(&last);
}
// Right-click acts as Enter while a command runs (the classic CAD default); otherwise it opens
// the shortcut menu (Repeat, Clipboard, …).
let idle = app.session.running.is_none();
if inside && pressed_secondary && !idle {
let _ = app.session.input(Input::Enter);
}
if idle {
crate::context_menu::show(app, &resp);
}
// Hover highlight (throttled to cursor movement).
+66
View File
@@ -0,0 +1,66 @@
//! The system clipboard: Cut/Copy/Paste key presses, and the drawing clipboard mirrored as DXF
//! text so it can be pasted into another CADCraft window.
//!
//! egui turns Cmd/Ctrl+C, X and V into `Copy`, `Cut` and `Paste(text)` events instead of key
//! presses, so the clipboard shortcuts are read from those. A `Paste` arrives only when the system
//! clipboard holds text; every copy therefore puts its DXF text there.
use std::hash::{DefaultHasher, Hash, Hasher};
use cadcraft_engine::cmd::clipboard;
use crate::CadApp;
/// egui memory: the clipboard copy last mirrored, and the hash of the text it became.
fn serial_id() -> egui::Id {
egui::Id::new("cc_clipboard_serial")
}
fn text_id() -> egui::Id {
egui::Id::new("cc_clipboard_text")
}
/// Line endings normalised (pasted text arrives with `\n`), so our own text is recognised.
fn hash(text: &str) -> u64 {
let mut h = DefaultHasher::new();
text.replace("\r\n", "\n").hash(&mut h);
h.finish()
}
/// Put a new copy (COPYCLIP, CUTCLIP, COPYBASE from anywhere) on the system clipboard, once.
pub fn mirror(app: &CadApp, ctx: &egui::Context) {
let Some(src) = &app.session.clipboard_source else { return };
if src.uid.is_none() || ctx.data(|d| d.get_temp::<u64>(serial_id())) == Some(src.serial) {
return;
}
ctx.data_mut(|d| d.insert_temp(serial_id(), src.serial));
if let Some(text) = clipboard::system_text(&app.session) {
ctx.data_mut(|d| d.insert_temp(text_id(), hash(&text)));
ctx.copy_text(text);
}
}
/// The commands the Cut/Copy/Paste events of this frame ask for. Pasted DXF text that isn't our
/// own last copy (another CADCraft's) becomes the clipboard first.
pub fn shortcut_commands(app: &mut CadApp, ctx: &egui::Context) -> Vec<&'static str> {
let (events, shift) = ctx.input(|i| {
let ev: Vec<egui::Event> =
i.events.iter().filter(|e| matches!(e, egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_))).cloned().collect();
(ev, i.modifiers.shift)
});
let mut out = Vec::new();
for e in events {
match e {
egui::Event::Copy => out.push(if shift { "copybase" } else { "copyclip" }),
egui::Event::Cut => out.push("cutclip"),
egui::Event::Paste(text) => {
let h = hash(&text);
if ctx.data(|d| d.get_temp::<u64>(text_id())) != Some(h) && clipboard::load_system_text(&mut app.session, &text) {
ctx.data_mut(|d| d.insert_temp(text_id(), h));
}
out.push(if shift { "pasteblock" } else { "pasteclip" });
}
_ => {}
}
}
out
}
+69
View File
@@ -0,0 +1,69 @@
//! The canvas's right-click menu while no command runs, after AutoCAD's default and edit-mode
//! shortcut menus: Repeat the last command, the Clipboard commands, editing commands for a
//! selection, Undo/Redo, Pan/Zoom, Quick Select and Find. (While a command runs, a right-click
//! is Enter.) Every item starts a command, like choosing it from the menu bar.
use cadcraft_engine::find_command;
use crate::CadApp;
use crate::menus::{Entry, activate, entry_ui};
/// Clipboard ▸ (AutoCAD's order).
const CLIPBOARD: &[&str] = &["cutclip", "copyclip", "copybase", "pasteclip", "pasteblock", "pasteorig"];
/// With a selection: commands that act on it, and its label when it differs from the command's.
const SELECTION: &[(&str, Option<&str>)] = &[
("erase", None),
("move", None),
("copy", Some("Copy Selection")),
("scale", None),
("rotate", None),
("ai_deselect", None),
("properties", None),
];
const VIEW: &[&str] = &["u", "redo", "pan", "zoom"];
const TOOLS: &[&str] = &["qselect", "find"];
fn item(app: &CadApp, id: &str, label: Option<&str>) -> Option<Entry> {
let spec = find_command(id)?;
Some(Entry::Item {
label: label.unwrap_or(spec.label).to_string(),
id: id.to_string(),
shortcut: spec.shortcut.map(str::to_string),
enabled: (spec.enabled)(&app.session).is_ok(),
})
}
/// The menu's groups of entries (separated by lines).
pub fn entries(app: &CadApp) -> Vec<Vec<Entry>> {
let mut groups = Vec::new();
if let Some(last) = app.session.last_command.as_deref().and_then(find_command) {
let label = crate::tf!("Repeat {command}", command = last.id.to_ascii_uppercase());
groups.push(vec![Entry::Item { label, id: last.id.to_string(), shortcut: None, enabled: (last.enabled)(&app.session).is_ok() }]);
}
let clip = CLIPBOARD.iter().filter_map(|id| item(app, id, None)).collect();
groups.push(vec![Entry::Sub { label: "Clipboard".into(), children: clip }]);
if !app.session.selection().is_empty() {
groups.push(SELECTION.iter().filter_map(|(id, label)| item(app, id, *label)).collect());
}
groups.push(VIEW.iter().filter_map(|id| item(app, id, None)).collect());
groups.push(TOOLS.iter().filter_map(|id| item(app, id, None)).collect());
groups
}
/// Attach the menu to the canvas response: it opens on a right-click.
pub fn show(app: &mut CadApp, resp: &egui::Response) {
let mut clicked = None;
resp.context_menu(|ui| {
for (i, group) in entries(app).iter().enumerate() {
if i > 0 {
ui.separator();
}
for e in group {
entry_ui(ui, e, &mut clicked);
}
}
});
if let Some(id) = clicked {
activate(app, &id);
}
}
+5
View File
@@ -101,6 +101,7 @@
Circle Коло
Circumference Довжина кола
Clear Очистити
Clipboard Буфер обміну
Close Закрити
Close All Закрити всі
Closed Замкнений
@@ -131,6 +132,7 @@
Copy Копіювати
Copy Layout Копіювати аркуш
Copy Nested Objects Копіювати вкладені об'єкти
Copy Selection Копіювати вибране
Copy with Base Point Копіювати з базовою точкою
Count Кількість
Create Block Створити блок
@@ -164,6 +166,7 @@
Delta X Різниця X
Delta Y Різниця Y
Description Опис
Deselect All Скасувати вибір
Diameter Діаметр
Dim style Розмірний стиль
Dimension Розміри
@@ -502,6 +505,7 @@
Parameters Manager Диспетчер параметрів
Parametric Параметризація
Paste Вставити
Paste as Block Вставити як блок
Paste to Original Coordinates Вставити у вихідні координати
Path Array Масив уздовж траєкторії
Pattern Візерунок
@@ -576,6 +580,7 @@
Rename Layout Перейменувати аркуш
Rename Text Style Перейменувати текстовий стиль
Rename... Перейменувати...
Repeat {command} Повторити {command}
Requires a point (x,y, @dx,dy or @d<a). Потрібна точка (x,y, @dx,dy або @d<a).
Reset Palettes Скинути палітри
Restore Відновити
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
101
102
103
104
105
106
107
132
133
134
135
136
137
138
166
167
168
169
170
171
172
505
506
507
508
509
510
511
580
581
582
583
584
585
586
+2
View File
@@ -10,8 +10,10 @@
pub mod canvas;
pub mod chrome;
pub mod clipboard;
pub mod closing;
pub mod cmdline;
pub mod context_menu;
pub mod control;
pub mod credits;
pub mod dialogs;
+6 -3
View File
@@ -354,7 +354,7 @@ pub(crate) fn shortcut_label(s: &str, mac: bool) -> String {
format!("{shift}{cmd}{}", s.replace("Cmd+", "").replace("Shift+", ""))
}
fn entry_ui(ui: &mut egui::Ui, e: &Entry, clicked: &mut Option<String>) {
pub(crate) fn entry_ui(ui: &mut egui::Ui, e: &Entry, clicked: &mut Option<String>) {
match e {
Entry::Item { label, id, shortcut, enabled } => {
let mut b = egui::Button::new(crate::i18n::t(label));
@@ -424,6 +424,7 @@ pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) {
(sc(cmd_shift, Key::C), "copybase"),
(sc(cmd, Key::C), "copyclip"),
(sc(cmd, Key::X), "cutclip"),
(sc(cmd_shift, Key::V), "pasteblock"),
(sc(cmd, Key::V), "pasteclip"),
(sc(cmd, Key::Num1), "ui.toggle.palettes"),
(sc(cmd, Key::Num3), "ui.toggle.toolsets"),
@@ -438,13 +439,15 @@ pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) {
(sc(Modifiers::NONE, Key::F11), "otrack"),
(sc(Modifiers::NONE, Key::F12), "dynmode"),
];
crate::clipboard::mirror(app, ctx);
if ctx.egui_wants_keyboard_input() || app.closing.is_some() {
return;
}
let mut fire = Vec::new();
// Cmd/Ctrl+C, X and V arrive as clipboard events, not key presses.
let mut fire = crate::clipboard::shortcut_commands(app, ctx);
ctx.input_mut(|i| {
for (s, id) in pairs {
if i.consume_shortcut(s) {
if i.consume_shortcut(s) && !fire.contains(id) {
fire.push(*id);
}
}
+96
View File
@@ -0,0 +1,96 @@
//! The canvas's right-click menu (#325): Repeat, the Clipboard commands with AutoCAD's rules for
//! what is available, and the selection commands.
use cadcraft_engine::Session;
use cadcraft_ui_egui::menus::Entry;
use cadcraft_ui_egui::{CadApp, Services, context_menu};
use serde_json::json;
/// (label, id, enabled) of the items, with submenu items prefixed by "Sub/".
fn items(app: &CadApp) -> Vec<(String, String, bool)> {
fn walk(prefix: &str, es: &[Entry], out: &mut Vec<(String, String, bool)>) {
for e in es {
match e {
Entry::Item { label, id, enabled, .. } => out.push((format!("{prefix}{label}"), id.clone(), *enabled)),
Entry::Sub { label, children } => walk(&format!("{prefix}{label}/"), children, out),
}
}
}
let mut out = Vec::new();
for g in context_menu::entries(app) {
walk("", &g, &mut out);
}
out
}
fn enabled(app: &CadApp, id: &str) -> bool {
items(app).iter().find(|(_, i, _)| i == id).is_some_and(|(_, _, e)| *e)
}
#[test]
fn context_menu_offers_the_clipboard() {
let mut app = CadApp::new(Session::new(), Services::default());
let labels: Vec<String> = items(&app).into_iter().map(|(l, _, _)| l).collect();
for l in [
"Clipboard/Cut",
"Clipboard/Copy",
"Clipboard/Copy with Base Point",
"Clipboard/Paste",
"Clipboard/Paste as Block",
"Clipboard/Paste to Original Coordinates",
"Undo",
"Redo",
"Pan",
"Zoom",
] {
assert!(labels.iter().any(|x| x == l), "{l} missing from {labels:?}");
}
assert!(!labels.iter().any(|l| l.starts_with("Repeat")), "no command yet");
assert!(!enabled(&app, "pasteclip"), "nothing to paste");
app.cmdline("LINE 0,0 10,0");
app.cmdline("");
app.run("copyclip", json!({ "handles": app.session.doc().unwrap().model.handles().iter().map(|h| h.hex()).collect::<Vec<_>>() })).unwrap();
let repeat = items(&app).into_iter().next().unwrap();
assert_eq!((repeat.0.as_str(), repeat.1.as_str()), ("Repeat LINE", "line"));
assert!(enabled(&app, "pasteclip") && enabled(&app, "pasteblock"));
assert!(!enabled(&app, "pasteorig"), "Paste to Original Coordinates pastes into another drawing");
app.run("new", json!({})).unwrap();
assert!(enabled(&app, "pasteorig"));
context_menu_selection(&mut app);
}
/// With a selection, the editing commands and Deselect All.
fn context_menu_selection(app: &mut CadApp) {
assert!(!items(app).iter().any(|(_, id, _)| id == "ai_deselect"));
app.run("pasteorig", json!({})).unwrap();
assert_eq!(app.session.selection().len(), 1, "pasted objects are selected");
let ids: Vec<String> = items(app).into_iter().map(|(_, id, _)| id).collect();
for id in ["erase", "move", "copy", "ai_deselect", "properties"] {
assert!(ids.iter().any(|x| x == id), "{id} missing from {ids:?}");
}
app.run("ai_deselect", json!({})).unwrap();
assert!(app.session.selection().is_empty());
}
/// egui delivers Cmd/Ctrl+C, X and V as clipboard events, not key presses: they run the
/// clipboard commands.
#[test]
fn clipboard_events_run_the_clipboard_commands() {
let mut app = CadApp::new(Session::new(), Services::default());
app.run("line", json!({ "points": [[0, 0], [10, 0]] })).unwrap();
let h = app.session.doc().unwrap().model.handles();
app.session.set_selection(h);
let ctx = egui::Context::default();
let frame = |app: &mut CadApp, events: Vec<egui::Event>| {
ctx.begin_pass(egui::RawInput { events, ..Default::default() });
cadcraft_ui_egui::menus::shortcuts(app, &ctx);
ctx.end_pass().textures_delta.clear();
};
frame(&mut app, vec![egui::Event::Copy]);
assert_eq!(app.session.clipboard.len(), 1, "Copy with a selection copies it");
frame(&mut app, vec![egui::Event::Paste("text from another program".into())]);
assert_eq!(app.session.running.as_ref().map(|r| r.id.as_str()), Some("pasteclip"));
assert!(app.session.prompt_text().contains("insertion point"));
}
+4 -3
View File
@@ -187,9 +187,10 @@ down-arrow options menu (DYNPROMPT), and DYNDIM.
shortcuts. AutoCAD for Mac's defaults are similar in number; F4 (3D osnap), F5 (isoplane), F6
(dynamic UCS) are missing.
- Delete erases the selection on macOS (#64); Escape cancels.
- **No right-click context menus** anywhere: right-click is Enter, clear selection or repeat
(`canvas.rs:651-660`). AutoCAD has context menus on the canvas (per selection, per command),
the command line, palettes and tabs.
- Right-click on the canvas is Enter while a command runs; otherwise it opens a shortcut menu
(Repeat, Clipboard ▸ Cut/Copy/Copy with Base Point/Paste/Paste as Block/Paste to Original
Coordinates, the selection's editing commands, Undo/Redo, Pan/Zoom; #325). Still missing:
AutoCAD's per-command menus and the menus of the command line, palettes and tabs.
## Revision history