diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs new file mode 100644 index 0000000..eb188bb --- /dev/null +++ b/crates/engine/src/cmd/clipboard.rs @@ -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, + /// The layers, linetypes, styles and blocks the objects reference (and the source's header). + pub defs: Arc, + /// 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, 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, base: Vec2, defs: Drawing, uid: Option) { + 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> { + 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 { + 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 { + 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 = 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, + linetypes: HashSet, + text_styles: HashSet, + dim_styles: HashSet, + mleader_styles: HashSet, + table_styles: HashSet, + blocks: HashSet, +} + +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 { + 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 = 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(all: &[T], used: &HashSet, name: fn(&T) -> &str) -> Vec { + 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 { + fn add(ours: &mut Vec, 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 { + let mut out = BTreeMap::new(); + let mut taken: BTreeSet = 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) -> 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 +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 996bc4d..709421a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -34,11 +34,23 @@ pub fn specs() -> Vec { 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) -> Result = 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 { Ok(json!({ "cut": n })) } -pub(crate) fn paste(s: &mut Session, offset: Vec2) -> Result> { - 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> { + clipboard::paste(s, &Mat3::translate(offset)) } fn run_paste(s: &mut Session, p: &Value) -> Result { @@ -207,6 +206,16 @@ fn run_paste(s: &mut Session, p: &Value) -> Result { Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) } +fn run_pasteblock(s: &mut Session, p: &Value) -> Result { + 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 { let r = paste(s, Vec2::ZERO)?; Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) @@ -238,6 +247,11 @@ fn run_selectall(s: &mut Session, _p: &Value) -> Result { Ok(json!({ "selected": n })) } +fn run_deselect(s: &mut Session, _p: &Value) -> Result { + s.set_selection(Vec::new()); + Ok(json!({ "selected": 0 })) +} + fn run_select(s: &mut Session, p: &Value) -> Result { if bool_or(p, "clear", false) { s.set_selection(Vec::new()); @@ -263,20 +277,34 @@ fn run_select(s: &mut Session, p: &Value) -> Result { Ok(json!({ "selected": sel.len(), "handles": sel.iter().map(|h| h.hex()).collect::>() })) } +#[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 { 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 { 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)?; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 174d5c6..578b56b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -4,6 +4,7 @@ mod annotate; mod array; mod blocks; +pub mod clipboard; pub mod constraints; mod draw; mod draw2; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a68f468..00c6522 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -350,6 +350,8 @@ pub struct Session { pub last_dim: Option, /// 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, } 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 { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 694cb4b..bc2f291 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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)); diff --git a/crates/engine/tests/clipboard.rs b/crates/engine/tests/clipboard.rs new file mode 100644 index 0000000..03ec408 --- /dev/null +++ b/crates/engine/tests/clipboard.rs @@ -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) { + 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 { + s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect() +} + +fn model(s: &Session) -> Vec { + 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 = 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 = + 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> = 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; +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 65f5ecb..c5e54e8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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). diff --git a/crates/ui-egui/src/clipboard.rs b/crates/ui-egui/src/clipboard.rs new file mode 100644 index 0000000..af612ab --- /dev/null +++ b/crates/ui-egui/src/clipboard.rs @@ -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::(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 = + 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::(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 +} diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs new file mode 100644 index 0000000..3e6c39f --- /dev/null +++ b/crates/ui-egui/src/context_menu.rs @@ -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 { + 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> { + 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); + } +} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d7e0880..6ea081d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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 String { format!("{shift}{cmd}{}", s.replace("Cmd+", "").replace("Shift+", "")) } -fn entry_ui(ui: &mut egui::Ui, e: &Entry, clicked: &mut Option) { +pub(crate) fn entry_ui(ui: &mut egui::Ui, e: &Entry, clicked: &mut Option) { 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); } } diff --git a/crates/ui-egui/tests/context_menu.rs b/crates/ui-egui/tests/context_menu.rs new file mode 100644 index 0000000..feb570c --- /dev/null +++ b/crates/ui-egui/tests/context_menu.rs @@ -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 = 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::>() })).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 = 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| { + 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")); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b9d65a1..3f6f6e2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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