feat(select): PICKADD/PICKDRAG command-line toggles (#226)

PICKADD 0 = click/box/lasso replaces the selection (Shift toggles),
empty click clears; PICKADD 1 (default) keeps today's accumulate.
PICKDRAG 1 = press-drag spans a rectangle marquee through the box
machinery; PICKDRAG 0 (default) keeps the freeform lasso. Both persist
in settings.json. Also re-points the start-page gate test at the
start_allowed fn it moved into (#388/389 refactor left it broken).

Closes #226

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Fable 5 committed 2026-07-22 21:35:37 +03:00
1 parent 90fd69df06
commit 0c578b03f3
7 files changed
+250 -17

No files matched your search

+102 -3
View File
@@ -620,11 +620,13 @@ mod tests {
// rather than by dispatching: DONATE / REPORT / WEBVERSION shell out to
// a real browser, which a test must not do.
let dispatch_src = include_str!("commands/mod.rs");
// The gate's allow-list lives in the `start_allowed` fn (#388/389);
// its first `}` closes the `matches!` body, past every name.
let gate = dispatch_src
.split("is_start")
.split("pub fn start_allowed")
.nth(1)
.and_then(|s| s.split('{').next())
.expect("the is_start gate moved — re-point this test");
.and_then(|s| s.split('}').next())
.expect("the start_allowed gate moved — re-point this test");
// DONATE/REPORT/WEBVERSION are the welcome page's own buttons; the rest
// are ribbon tools that configure the application, not a drawing.
let standalone = [
@@ -654,6 +656,103 @@ mod tests {
}
}
/// PICKADD / PICKDRAG (#226): the command flips the live flag both via the
/// inline form and the two-step ValuePrompt flow.
#[test]
fn lasso_press_drag_selects() {
// Press-drag lasso must select crossed entities — regression probe
// for the #226 PICKDRAG work (both PICKDRAG modes complete through
// the poly path).
use crate::app::Message;
for (add, rect) in [(true, false), (true, true), (false, false), (false, true)] {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
app.pick_add = add;
app.pick_drag_rect = rect;
let _ = app.run_command_line("LINE 0,0 10,10");
app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0);
let _ = app.run_command_line("ZOOM EXTENTS");
// Both directions. Crossing = a right → left diagonal sweep (the
// freeform path may be degenerate — crossing counts hits).
// Window = a left → right perimeter walk so the freeform ring
// actually ENCLOSES the line (a diagonal has no area).
let path: Vec<(f32, f32)> = if !add && rect {
// Rectangle window: a simple left → right diagonal spans it.
(0..=10)
.map(|k| {
let t = k as f32 / 10.0;
(20.0 + t * 760.0, 20.0 + t * 560.0)
})
.collect()
} else if add {
let (sx, sy, ex, ey) = (780.0f32, 580.0f32, 20.0f32, 20.0f32);
(0..=10)
.map(|k| {
let t = k as f32 / 10.0;
(sx + t * (ex - sx), sy + t * (ey - sy))
})
.collect()
} else {
vec![
(20.0, 20.0),
(400.0, 20.0),
(780.0, 20.0),
(780.0, 300.0),
(780.0, 580.0),
(400.0, 580.0),
(20.0, 580.0),
(20.0, 300.0),
]
};
let _ = app.update(Message::ViewportMove(iced::Point::new(path[0].0, path[0].1)));
let _ = app.update(Message::ViewportLeftPress);
std::thread::sleep(std::time::Duration::from_millis(180));
for &(x, y) in &path {
let _ = app.update(Message::ViewportMove(iced::Point::new(x, y)));
}
{
let sel = app.tabs[i].scene.selection.borrow();
assert!(sel.left_dragging, "drag must start (add={add} rect={rect})");
if rect {
// Rectangle mode drives the box machinery, not the lasso.
assert!(
sel.box_anchor.is_some() && sel.box_current.is_some() && !sel.poly_active,
"rect marquee must arm the box (add={add})"
);
} else {
assert!(sel.poly_active, "lasso must start (add={add})");
assert!(sel.poly_points.len() >= 3, "lasso points (add={add})");
}
}
let _ = app.update(Message::ViewportLeftRelease);
assert!(
!app.tabs[i].scene.selected.is_empty(),
"marquee must select the line (add={add} rect={rect})"
);
}
}
#[test]
fn pickadd_command_flips_flag() {
let mut app = OpenCADStudio::new_for_test();
// The Start tab blocks drawing commands — open a drawing first.
app.automation_op(r#"{"op":"new"}"#);
// The boot path may have restored a persisted value — normalize.
app.pick_add = true;
app.pick_drag_rect = false;
let _ = app.run_command_line("PICKADD 0");
assert!(!app.pick_add, "PICKADD 0 must switch to replace mode");
let _ = app.run_command_line("PICKADD 1");
assert!(app.pick_add);
// Two-step: bare command then the value, like typing 1 + Enter
// (feed_active_cmd is the same path the GUI submit offers first).
let _ = app.run_command_line("PICKDRAG");
assert!(app.tabs[app.active_tab].active_cmd.is_some(), "prompt must open");
app.feed_active_cmd("1");
assert!(app.pick_drag_rect, "PICKDRAG 1 via the prompt must switch");
}
#[test]
fn matchprop_matches_text_style_and_height() {
// MATCHPROP between text objects must carry the text-specific
+2
View File
@@ -290,6 +290,8 @@ inventory::submit!(crate::command::CommandRegistration {
// Restore the last-erased objects.
"OOPS",
// Plan (top) view.
"PICKADD",
"PICKDRAG",
"PLAN",
// Current object colour (CECOLOR).
"COLOR",
+64
View File
@@ -652,6 +652,70 @@ impl OpenCADStudio {
}
}
// ── PICKADD / PICKDRAG — selection UX (#226, app settings) ───
// Bare form reports; `<name> 0|1` sets and persists. Defaults keep
// today's behaviour (PICKADD 1, PICKDRAG 0).
"PICKADD" | "PICKDRAG" => {
use crate::command::ValuePromptCommand;
let (name, prompt) = if cmd == "PICKADD" {
("PICKADD", "PICKADD 1 = click adds to selection (default), 0 = click replaces <Enter reports>:")
} else {
("PICKDRAG", "PICKDRAG 0 = press-drag lassoes (default), 1 = press-drag draws a rectangle <Enter reports>:")
};
let c = ValuePromptCommand::new(name, prompt);
self.command_line.push_info(&c.prompt());
self.tabs[i].active_cmd = Some(Box::new(c));
}
cmd if cmd.starts_with("PICKADD ") || cmd.starts_with("PICKDRAG ") => {
let is_add = cmd.starts_with("PICKADD");
let arg = cmd.splitn(2, ' ').nth(1).unwrap_or("").trim();
if arg.is_empty() {
let v = if is_add {
u8::from(self.pick_add)
} else {
u8::from(self.pick_drag_rect)
};
self.command_line.push_output(&format!(
"{} = {v}",
if is_add { "PICKADD" } else { "PICKDRAG" }
));
} else {
match arg {
"0" | "1" => {
let on = arg == "1";
if is_add {
self.pick_add = on;
self.command_line.push_output(&format!(
"PICKADD = {} ({})",
arg,
if on {
"click adds to selection"
} else {
"click replaces selection, Shift toggles"
}
));
} else {
self.pick_drag_rect = on;
self.command_line.push_output(&format!(
"PICKDRAG = {} ({})",
arg,
if on {
"press-drag draws a rectangle"
} else {
"press-drag lassoes"
}
));
}
self.persist_settings_if_changed();
}
_ => self.command_line.push_error(&format!(
"{}: expected 0 or 1.",
if is_add { "PICKADD" } else { "PICKDRAG" }
)),
}
}
}
// ── SETVAR — read / write system variables ───────────────────
// SETVAR <name> → report the value
// SETVAR <name> <value> → set it
+8
View File
@@ -293,6 +293,12 @@ pub(super) struct OpenCADStudio {
/// Selection cycling: clicking where objects overlap opens a list box
/// to pick which one; the pick is added to the current selection.
selection_cycling: bool,
/// PICKADD (#226): true (default) = clicks accumulate; false = OS-style
/// replace-on-click with Shift toggling.
pick_add: bool,
/// PICKDRAG (#226): false (default) = press-drag lassoes; true =
/// press-drag draws a rectangle marquee.
pick_drag_rect: bool,
/// Frame-budget HUD (Phase 5.3): overlays the last wire re-tessellation
/// cost on the active viewport. Toggled by the `PERF` command.
perf_hud: bool,
@@ -2277,6 +2283,8 @@ impl OpenCADStudio {
clean_screen: false,
quick_properties: false,
selection_cycling: false,
pick_add: true,
pick_drag_rect: false,
perf_hud: false,
cycle_candidates: None,
pre_cmd_tangent: None,
+9
View File
@@ -135,6 +135,13 @@ pub struct UserSettings {
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command).
/// 0 disables autosave.
pub savetime_min: i32,
/// PICKADD (#226): `true` (default) = a plain click ADDS to the selection
/// (Shift removes); `false` = OS-style — a click REPLACES the selection
/// and Shift+click toggles membership.
pub pick_add: bool,
/// PICKDRAG (#226): `false` (default) = press-drag draws the freeform
/// lasso; `true` = press-drag draws a rectangle marquee instead.
pub pick_drag_rect: bool,
/// Persisted viewport background colours (0–255 RGB); `None` = app default
/// (dark grey model / off-white paper). Applied to every drawing tab on
/// launch and to tabs opened later, so a chosen background survives restarts
@@ -162,6 +169,8 @@ impl Default for UserSettings {
backup_on_save: true,
file_assoc_enabled: true,
savetime_min: 10,
pick_add: true,
pick_drag_rect: false,
bg_color: None,
paper_bg_color: None,
}
+4
View File
@@ -85,6 +85,8 @@ impl OpenCADStudio {
backup_on_save: self.backup_on_save,
file_assoc_enabled: self.file_assoc_enabled,
savetime_min: self.savetime_min,
pick_add: self.pick_add,
pick_drag_rect: self.pick_drag_rect,
bg_color: self.default_bg_color.map(f4_to_u3),
paper_bg_color: self.default_paper_bg_color.map(f4_to_u3),
}
@@ -110,6 +112,8 @@ impl OpenCADStudio {
self.backup_on_save = s.backup_on_save;
self.file_assoc_enabled = s.file_assoc_enabled;
self.savetime_min = s.savetime_min;
self.pick_add = s.pick_add;
self.pick_drag_rect = s.pick_drag_rect;
self.default_bg_color = s.bg_color.map(u3_to_f4);
self.default_paper_bg_color = s.paper_bg_color.map(u3_to_f4);
// Push the restored background onto every drawing tab that exists now
+61 -14
View File
@@ -547,11 +547,20 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
.unwrap_or(u128::MAX);
if !sel.left_dragging && elapsed_ms >= POLY_START_DELAY_MS && dist2 > 9.0 {
sel.left_dragging = true;
sel.poly_active = true;
sel.poly_crossing = p.x < press.x;
sel.poly_points.clear();
sel.poly_points.push(press);
sel.poly_points.push(p);
if self.pick_drag_rect {
// PICKDRAG 1 (#226): press-drag spans a RECTANGLE
// marquee — drive the existing box machinery (its
// overlay and completion) instead of the lasso.
sel.box_anchor = Some(press);
sel.box_current = Some(p);
sel.box_crossing = p.x < press.x;
} else {
sel.poly_active = true;
sel.poly_crossing = p.x < press.x;
sel.poly_points.clear();
sel.poly_points.push(press);
sel.poly_points.push(p);
}
} else if sel.left_dragging && sel.poly_active {
if sel.poly_points.last().map_or(true, |lp| {
let ddx = p.x - lp.x;
@@ -560,6 +569,11 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
}) {
sel.poly_points.push(p);
}
} else if sel.left_dragging {
if let Some(a) = sel.box_anchor {
sel.box_current = Some(p);
sel.box_crossing = p.x < a.x;
}
}
} else if sel.box_anchor.is_some() {
sel.box_current = Some(p);
@@ -2322,19 +2336,15 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
return Task::none();
}
let (is_down2, is_dragging, box_anchor, box_crossing, _vp_size, elapsed_ms) = {
let (is_down2, is_dragging, box_anchor, box_crossing, _vp_size, poly_drag) = {
let sel = self.tabs[i].scene.selection.borrow();
let elapsed = sel
.left_press_time
.map(|t| Instant::now().duration_since(t).as_millis())
.unwrap_or(u128::MAX);
(
sel.left_down,
sel.left_dragging,
sel.box_anchor,
sel.box_crossing,
sel.vp_size,
elapsed,
sel.poly_active,
)
};
@@ -2358,7 +2368,10 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
};
if is_dragging {
if elapsed_ms < POLY_START_DELAY_MS {
if !poly_drag {
// PICKDRAG 1 (#226): the press-drag spanned a
// rectangle through the box machinery — complete
// it here on release.
if let Some(a) = box_anchor {
let crossing = box_crossing;
let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds);
@@ -2395,11 +2408,15 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
// (issue #83): a plain box adds to the current
// selection, Shift+box removes the boxed
// entities. Esc / empty-space click still clears.
// PICKADD 0 (#226): a plain box REPLACES.
if self.shift_down {
for h in &handles {
self.tabs[i].scene.deselect_entity(*h);
}
} else {
if !self.pick_add && !handles.is_empty() {
self.tabs[i].scene.deselect_all();
}
for h in &handles {
self.tabs[i].scene.select_entity(*h, false);
}
@@ -2462,6 +2479,12 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
self.tabs[i].scene.deselect_entity(*h);
}
} else {
// PICKADD 0 (#226): a plain marquee REPLACES
// the selection (empty results still leave it
// alone, matching the box rule).
if !self.pick_add && !handles.is_empty() {
self.tabs[i].scene.deselect_all();
}
for h in &handles {
self.tabs[i].scene.select_entity(*h, false);
}
@@ -2560,10 +2583,24 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
// Individual picks accumulate (issue #47):
// each plain click adds to the selection,
// Shift+click removes the picked entity.
// PICKADD 0 (#226): a plain click
// REPLACES the selection instead and
// Shift+click toggles membership.
if self.shift_down {
self.tabs[i].scene.deselect_entity(handle);
if !self.pick_add
&& !self.tabs[i].scene.selected.contains(&handle)
{
self.tabs[i].scene.select_entity(handle, false);
self.tabs[i]
.scene
.expand_selection_for_groups(&[handle]);
} else {
self.tabs[i].scene.deselect_entity(handle);
}
} else {
self.tabs[i].scene.select_entity(handle, false);
self.tabs[i]
.scene
.select_entity(handle, !self.pick_add);
self.tabs[i].scene.expand_selection_for_groups(&[handle]);
}
self.refresh_properties();
@@ -2575,6 +2612,12 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
// add to it (issue #83). The box completion
// (or Esc) decides what happens to the
// selection.
// PICKADD 0 (#226): OS convention — the
// empty click also drops the selection.
if !self.pick_add && !self.shift_down {
self.tabs[i].scene.deselect_all();
self.refresh_properties();
}
// Pin the anchor to the world point under it
// so a zoom/pan mid-drag re-projects it
// instead of leaving it frozen in pixels
@@ -2631,11 +2674,15 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
// current selection, Shift+box removes the boxed
// entities. An empty box leaves the selection alone
// so an accidental empty drag never discards it.
// PICKADD 0 (#226): a plain box REPLACES instead.
if self.shift_down {
for h in &handles {
self.tabs[i].scene.deselect_entity(*h);
}
} else {
if !self.pick_add && !handles.is_empty() {
self.tabs[i].scene.deselect_all();
}
for h in &handles {
self.tabs[i].scene.select_entity(*h, false);
}