From 25c965890877313d766f2827954f0ebb20d51355 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 17:23:03 +0200 Subject: [PATCH] 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 --- crates/engine/src/cmd/machines.rs | 32 +++++++- crates/engine/src/lib.rs | 52 +++++++++++- crates/engine/src/select.rs | 20 ++++- crates/engine/tests/select_keywords.rs | 109 +++++++++++++++++++++++++ docs/gaps.md | 2 +- docs/ui-parity.md | 2 +- 6 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 crates/engine/tests/select_keywords.rs diff --git a/crates/engine/src/cmd/machines.rs b/crates/engine/src/cmd/machines.rs index b3f017f0..72938d44 100644 --- a/crates/engine/src/cmd/machines.rs +++ b/crates/engine/src/cmd/machines.rs @@ -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, 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 { match i { Input::Pick(hs) => { - let before = self.picked.len(); - if self.removing { + let changed: Vec = if self.removing { + let gone: Vec = 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) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 86c1e6f8..30648e69 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -500,6 +500,8 @@ pub struct Session { pub pending_window: Option, /// A selection mode typed at "Select objects" (W, C, F, WP, CP) collecting its points. pub select_mode: Option, + /// 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, @@ -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> { 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) { diff --git a/crates/engine/src/select.rs b/crates/engine/src/select.rs index 3e927f16..804d509c 100644 --- a/crates/engine/src/select.rs +++ b/crates/engine/src/select.rs @@ -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> { + 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)) diff --git a/crates/engine/tests/select_keywords.rs b/crates/engine/tests/select_keywords.rs new file mode 100644 index 00000000..a97f18ca --- /dev/null +++ b/crates/engine/tests/select_keywords.rs @@ -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 { + let mut v: Vec = 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 { + 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 = 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::::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::::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()); +} diff --git a/docs/gaps.md b/docs/gaps.md index 991f0da1..0d32f629 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 `