Select objects: BOX, AUto, SIngle, Multiple, Group and Undo keywords (fixes #428) (#438)

Squash of PR #438 by @vangel76.

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-11 11:23:03 -04:00
1 parent 608aa225a7
commit 25c9658908
6 files changed
+208 -9

No files matched your search

+28 -4
View File
@@ -12,8 +12,13 @@ pub struct SelectPhase {
pub done: bool,
pub single: bool,
pub removing: bool,
/// What each selection so far changed, for Undo: (handles, whether they were removed).
pub history: Vec<(Vec<Handle>, bool)>,
}
/// Most selection steps Undo remembers.
const MAX_SELECT_HISTORY: usize = 1000;
pub enum SelOutcome {
/// Keep prompting.
More,
@@ -29,7 +34,7 @@ impl SelectPhase {
let pre = s.selection();
if !pre.is_empty() && s.settings.pickfirst {
s.remember_selection(&pre);
SelectPhase { picked: pre, done: true, single: false, removing: false }
SelectPhase { picked: pre, done: true, ..Default::default() }
} else {
SelectPhase::default()
}
@@ -50,18 +55,27 @@ impl SelectPhase {
pub fn feed(&mut self, s: &mut Session, i: &Input) -> Result<SelOutcome> {
match i {
Input::Pick(hs) => {
let before = self.picked.len();
if self.removing {
let changed: Vec<Handle> = if self.removing {
let gone: Vec<Handle> = self.picked.iter().copied().filter(|h| hs.contains(h)).collect();
self.picked.retain(|h| !hs.contains(h));
gone
} else {
let mut added = Vec::new();
for h in hs {
if !self.picked.contains(h) {
self.picked.push(*h);
added.push(*h);
}
}
added
};
if !changed.is_empty() {
if self.history.len() >= MAX_SELECT_HISTORY {
self.history.remove(0);
}
self.history.push((changed, self.removing));
}
let n = hs.len();
let _ = before;
if n > 0 {
s.echo(format!("{n} found, {} total", self.picked.len()));
} else {
@@ -77,6 +91,16 @@ impl SelectPhase {
match t.trim().to_ascii_lowercase().as_str() {
"r" | "remove" => self.removing = true,
"a" | "add" => self.removing = false,
// Undo: take back the last selection that changed the set.
"u" | "undo" => {
match self.history.pop() {
Some((hs, true)) => self.picked.extend(hs),
Some((hs, false)) => self.picked.retain(|h| !hs.contains(h)),
None => s.echo("Nothing to undo."),
}
s.echo(format!("{} total", self.picked.len()));
s.set_selection(self.picked.clone());
}
_ => s.echo("*Invalid selection*"),
}
Ok(SelOutcome::More)
+51 -1
View File
@@ -500,6 +500,8 @@ pub struct Session {
pub pending_window: Option<PendingWindow>,
/// A selection mode typed at "Select objects" (W, C, F, WP, CP) collecting its points.
pub select_mode: Option<select::SelectMode>,
/// SIngle typed at "Select objects": the next selection ends the prompt.
pub select_single: bool,
pub untitled_counter: u32,
/// The last dimension created (DIMCONTINUE / DIMBASELINE).
pub last_dim: Option<Handle>,
@@ -541,6 +543,7 @@ impl Session {
clipboard_base: Vec2::ZERO,
pending_window: None,
select_mode: None,
select_single: false,
untitled_counter: 0,
last_dim: None,
last_used: LastUsed::default(),
@@ -789,7 +792,18 @@ impl Session {
// Generic selection handling during "Select objects:" prompts.
let input = self.preprocess_selection(input)?;
match input {
Some(i) => self.feed(Some(i)),
Some(i) => {
// SIngle: the first selection that finds objects also ends the prompt.
let single = self.select_single && matches!(&i, Input::Pick(hs) if !hs.is_empty());
self.feed(Some(i))?;
if single {
self.select_single = false;
if self.current_prompt().is_some_and(|p| p.accept.select) {
self.feed(Some(Input::Enter))?;
}
}
Ok(())
}
None => Ok(()),
}
}
@@ -810,6 +824,20 @@ impl Session {
self.select_mode = Some(m);
return Ok(None);
}
if let Input::Text(t) | Input::Keyword(t) = &input
&& !prompt.keywords.iter().any(|k| k.eq_ignore_ascii_case(t.trim()))
{
match t.trim().to_ascii_lowercase().as_str() {
"si" | "single" => {
self.select_single = true;
return Ok(None);
}
// AUto (a pick, or a window where nothing is picked) is how points always select;
// Multiple picks the same way until Enter.
"au" | "auto" | "m" | "multiple" => return Ok(None),
_ => {}
}
}
let space = self.space();
match input {
Input::Point(p) => {
@@ -854,6 +882,25 @@ impl Session {
/// objects go to the command as a pick once the mode is complete.
fn select_mode_input(&mut self, input: Input) -> Result<Option<Input>> {
let Some(mut mode) = self.select_mode.take() else { return Ok(Some(input)) };
if mode.kind == select::SelectKind::Group {
return match input {
Input::Text(name) | Input::Keyword(name) => match select::select_group(self.doc()?, &self.space(), &name) {
Some(hs) => Ok(Some(Input::Pick(hs))),
None => {
self.echo(format!("No group named \"{}\".", name.trim()));
Ok(None)
}
},
// Enter or Esc leaves the group name prompt.
Input::Cancel => Ok(Some(input)),
Input::Enter => Ok(None),
_ => {
self.echo("Invalid group name.");
self.select_mode = Some(mode);
Ok(None)
}
};
}
match input {
Input::Point(p) => {
if mode.push(p) {
@@ -933,6 +980,9 @@ impl Session {
}
fn finish(&mut self, run: Running, cancelled: bool) {
// A typed selection mode or SIngle never outlives its command.
self.select_mode = None;
self.select_single = false;
if find_command(&run.id).is_some_and(|c| c.undoable)
&& let Err(e) = self.refuse_non_finite(None, &run.before, &run.selection_before, &run.id)
{
+18 -2
View File
@@ -284,6 +284,14 @@ pub fn select_polygon(d: &Drawing, space: &Space, poly: &[Vec2], crossing: bool)
out
}
/// The visible objects in `space` of the group called `name` (any case), or `None` when the
/// drawing has no such group.
pub fn select_group(d: &Drawing, space: &Space, name: &str) -> Option<Vec<Handle>> {
let g = d.groups.iter().find(|g| g.name.eq_ignore_ascii_case(name.trim()))?;
let store = d.space(space)?;
Some(g.members.iter().copied().filter(|h| store.get(*h).is_some_and(|e| d.is_visible(e))).collect())
}
/// A selection mode typed at "Select objects", collecting its points before it selects.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SelectKind {
@@ -297,6 +305,10 @@ pub enum SelectKind {
WPolygon,
/// `CP`: objects inside or crossing a polygon; Enter closes it.
CPolygon,
/// `BOX`: two corners; left to right is a window, right to left a crossing.
Box,
/// `G`: the objects of a group, by name.
Group,
}
/// Most points a fence or polygon collects (typed input is hostile).
@@ -318,18 +330,21 @@ impl SelectMode {
"f" | "fence" => SelectKind::Fence,
"wp" | "wpolygon" => SelectKind::WPolygon,
"cp" | "cpolygon" => SelectKind::CPolygon,
"box" => SelectKind::Box,
"g" | "group" => SelectKind::Group,
_ => return None,
};
Some(SelectMode { kind, pts: Vec::new() })
}
fn is_box(&self) -> bool {
matches!(self.kind, SelectKind::Window | SelectKind::Crossing)
matches!(self.kind, SelectKind::Window | SelectKind::Crossing | SelectKind::Box)
}
/// The prompt shown while the mode collects points.
pub fn prompt(&self) -> Prompt {
let last = self.pts.last().copied();
let (first, next) = match self.kind {
SelectKind::Window | SelectKind::Crossing => ("Specify first corner", "Specify opposite corner"),
SelectKind::Group => return Prompt::new("Enter group name", Accept::TEXT),
SelectKind::Window | SelectKind::Crossing | SelectKind::Box => ("Specify first corner", "Specify opposite corner"),
SelectKind::Fence => ("Specify first fence point", "Specify next fence point"),
SelectKind::WPolygon | SelectKind::CPolygon => ("Specify first polygon point", "Specify endpoint of line"),
};
@@ -352,6 +367,7 @@ impl SelectMode {
(SelectKind::Window | SelectKind::Crossing, [a, b, ..]) => {
Some(select_window(d, space, Bounds2::new(*a, *b), self.kind == SelectKind::Crossing))
}
(SelectKind::Box, [a, b, ..]) => Some(select_window(d, space, Bounds2::new(*a, *b), b.x < a.x)),
(SelectKind::Fence, pts) if pts.len() >= 2 => Some(select_fence(d, space, pts)),
(SelectKind::WPolygon | SelectKind::CPolygon, pts) if pts.len() >= 3 => {
Some(select_polygon(d, space, pts, self.kind == SelectKind::CPolygon))
+109
View File
@@ -0,0 +1,109 @@
//! Keywords typed at "Select objects" (#428): BOX, SIngle, AUto, Multiple, Group, Add, Remove and
//! Undo.
use cadcraft_engine::Session;
use cadcraft_engine::doc::EntityKind;
use serde_json::json;
/// Three lines on the X axis: A 0–10, B 12–20, C 30–40.
fn drawing() -> Session {
let mut s = Session::new();
for l in ["LINE 0,0 10,0", "LINE 12,0 20,0", "LINE 30,0 40,0"] {
s.cmdline(l).unwrap();
s.cmdline("").unwrap();
}
s
}
/// The start X of the lines left in the drawing.
fn left(s: &Session) -> Vec<f64> {
let mut v: Vec<f64> = s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some(l.a.x) } else { None }).collect();
v.sort_by(f64::total_cmp);
v
}
/// ERASE with `input` typed at "Select objects", then Enter.
fn erase(input: &str) -> Vec<f64> {
let mut s = drawing();
s.cmdline(input).unwrap();
assert!(s.running.is_some(), "{input}: still selecting");
s.cmdline("").unwrap();
assert!(s.running.is_none(), "{input}: Enter ends the selection");
left(&s)
}
#[test]
fn box_follows_the_direction_of_its_corners() {
// Left to right: a window, only B.
assert_eq!(erase("ERASE BOX 5,-1 25,1"), vec![0.0, 30.0]);
// Right to left: a crossing, A and B.
assert_eq!(erase("ERASE box 25,1 5,-1"), vec![30.0]);
}
#[test]
fn single_ends_the_prompt_with_the_first_selection() {
let mut s = drawing();
s.cmdline("ERASE SI").unwrap();
assert!(s.prompt_text().contains("Select objects"), "{}", s.prompt_text());
// An empty spot starts a window; its other corner selects B and ends ERASE at once.
s.cmdline("5,-1").unwrap();
assert!(s.running.is_some());
s.cmdline("25,1").unwrap();
assert!(s.running.is_none(), "SIngle needs no Enter");
assert_eq!(left(&s), vec![0.0, 30.0]);
// A pick, with the full word.
let mut s = drawing();
s.cmdline("ERASE single 35,0").unwrap();
assert!(s.running.is_none());
assert_eq!(left(&s), vec![0.0, 12.0]);
// SIngle is per prompt: the next ERASE waits for Enter again.
s.cmdline("ERASE 5,0").unwrap();
assert!(s.running.is_some());
}
#[test]
fn auto_and_multiple_keep_picking_until_enter() {
assert_eq!(erase("ERASE AU 15,0 35,0"), vec![0.0]);
assert_eq!(erase("ERASE M 15,0 35,0"), vec![0.0]);
assert_eq!(erase("ERASE multiple auto 5,0"), vec![12.0, 30.0]);
}
#[test]
fn group_selects_a_group_by_name() {
let mut s = drawing();
let hs: Vec<String> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();
s.execute("group", &json!({ "name": "Ends", "handles": [hs[0], hs[2]] })).unwrap();
s.cmdline("ERASE G").unwrap();
let p = s.current_prompt().unwrap().display();
assert!(p.contains("Enter group name"), "{p}");
s.cmdline("ends").unwrap();
assert!(s.prompt_text().contains("Select objects"), "{}", s.prompt_text());
s.cmdline("").unwrap();
assert_eq!(left(&s), vec![12.0]);
// An unknown name says so and goes back to "Select objects".
let mut s = drawing();
s.cmdline("ERASE group").unwrap();
s.cmdline("Nope").unwrap();
assert!(s.log.iter().any(|l| l.contains("No group named \"Nope\"")), "{:?}", s.log);
assert!(s.prompt_text().contains("Select objects"), "{}", s.prompt_text());
s.cmdline("").unwrap();
assert!(s.running.is_none());
assert_eq!(left(&s), vec![0.0, 12.0, 30.0]);
}
#[test]
fn remove_add_and_undo() {
// Remove takes B out of ALL; Add picks again.
assert_eq!(erase("ERASE ALL R 15,0 A 15,0"), Vec::<f64>::new());
assert_eq!(erase("ERASE ALL R 15,0"), vec![12.0]);
// Undo takes back the last pick…
assert_eq!(erase("ERASE 5,0 15,0 U"), vec![12.0, 30.0]);
// …or the last removal, and steps back further on each Undo.
assert_eq!(erase("ERASE ALL R 15,0 U"), Vec::<f64>::new());
assert_eq!(erase("ERASE 5,0 15,0 U U 35,0"), vec![0.0, 12.0]);
// Undo with nothing selected says so and keeps prompting.
let mut s = drawing();
s.cmdline("ERASE U").unwrap();
assert!(s.log.iter().any(|l| l == "Nothing to undo."), "{:?}", s.log);
assert!(s.running.is_some());
}
+1 -1
View File
@@ -65,7 +65,7 @@ Detail and evidence: [ui-parity.md](ui-parity.md).
| U3 | **[alpha blocker]** TK (temporary tracking, comes with object snap tracking, U1). FROM, M2P/MTP, `.x/.y/.xy` point filters, the `*` WCS prefix and the angle override `<a` landed at every point prompt (#392) | High | 1–2 |
| U4 | Tab snap cycling offers the closest snap of each mode, not every candidate; the snap menu has no Temporary track point (comes with U1). Typed snap overrides (`END`, `MID` … `NON`), the Shift/Ctrl+right-click snap menu and Tab cycling landed (#393) | Low | 1–2 |
| U5 | Hot-grip options (Base point, Copy, Undo, Reference) are shown but ignored; multiple hot grips; multifunctional grip menus (Add/Remove Vertex, Convert to Arc) | Medium-high | 6–10 |
| U6 | Selection keywords W, C, WP, CP, F, BOX, AU, SI, M, G; lasso (press-drag windows landed in #63); selection cycling; SELECTSIMILAR; ISOLATEOBJECTS/HIDEOBJECTS | Medium-high | 6–10 |
| U6 | Selection: lasso; selection cycling (a list of overlapping objects, SELECTIONCYCLING); the CLass keyword; isolation saved with the drawing (OBJECTISOLATIONMODE 1) and the status-bar isolate button. Press-drag windows (#63), the W, C, F, WP and CP keywords (#226), BOX, AUto, SIngle, Multiple, Group, Add, Remove and Undo, SELECTSIMILAR, and ISOLATEOBJECTS/HIDEOBJECTS/UNISOLATEOBJECTS (#428) landed | Medium | 3–5 |
| U7 | Right-click context menus (canvas, selection, command, command line, palettes, tabs) | Medium-high | 6–10 |
| U8 | Snaps missing on some objects: MID on circles/ellipses/splines; NOD on dimension points; INS on ATTDEF/MLEADER; TAN on ellipses/splines; GCEN on splines | Medium | 4–6 |
| U9 | Polar: additional angles, relative to last segment, PolarSnap; isometric snap/grid and ISODRAFT (dead toggle) | Medium | 4–6 |
+1 -1
View File
@@ -125,7 +125,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM.
| Feature | AutoCAD | CADCraft |
|---|---|---|
| Pick, implied window (left→right) and crossing (right→left) | yes | yes, coloured and dashed rectangles (`lib.rs:576-589`, `canvas.rs:823-836`) |
| Keywords W, C, WP, CP, F, BOX, AU, SI, M, G (group), CL (class) | yes | **All, Last, Previous, Add, Remove only** (`lib.rs:595-599`, `machines.rs:77-80`); fence only as a JSON parameter |
| Keywords W, C, WP, CP, F, BOX, AU, SI, M, G (group), CL (class) | yes | yes except CLass: W, C, F, WP, CP (#226); BOX, AUto, SIngle, Multiple, Group, Add, Remove, Undo (#428); ALL, Last, Previous |
| Press-drag selection windows (PICKDRAG) | yes | yes (#63, PICKDRAG = 2) |
| Lasso | yes | **no** |
| Selection cycling (SELECTIONCYCLING) | yes | toggle nothing reads |