forked from 1b-agent/opencadstudio-upstream
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:
1 parent
90fd69df06
commit
0c578b03f3
7 files changed
+250
-17
No files matched your search
+102
-3
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user