diff --git a/src/app/automation.rs b/src/app/automation.rs index f9591943d..75aa7362d 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -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 diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index c0085e5ac..271c03592 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -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", diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 82c0e5c7a..50a35fe36 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -652,6 +652,70 @@ impl OpenCADStudio { } } + // ── PICKADD / PICKDRAG — selection UX (#226, app settings) ─── + // Bare form reports; ` 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 :") + } else { + ("PICKDRAG", "PICKDRAG 0 = press-drag lassoes (default), 1 = press-drag draws a rectangle :") + }; + 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 → report the value // SETVAR → set it diff --git a/src/app/mod.rs b/src/app/mod.rs index a400019b4..d366dd001 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, diff --git a/src/app/settings.rs b/src/app/settings.rs index b5191df7e..2461e5130 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -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, } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 75b437787..0569b7f78 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -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 diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 1da7b5161..c77050374 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -547,11 +547,20 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { .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 { }) { 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 { 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 { }; 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 { // (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 { 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 { // 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 { // 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 { // 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); }