collective

Browse / github.com/storytold/cadcraft / patch

STRETCH and TRIM select like every other command; PICKDRAG 1 (stacked on #1555)

STRETCH and TRIM select like every other command; PICKDRAG 1 (stacked on #1555) Stacked on #1555 (its two commits come first; this PR adds the last one). Part of #1116. Follows up @grandixximo's note in #1555 that TRIM and STRETCH select differently. ## Problem STRETCH collected picks, windows and typed modes itself, and TRIM's crossing was a plain point prompt. So neither got what the shared selection has: a drag didn't close their windows, their windows were drawn differently, object snaps were on at their corners, and STRETCH had no selection cycling. ## Change - **STRETCH selects at the session's "Select objects"**, like MOVE or ERASE. It gets press-first picks (#10

Status
open
Score
0.00
Adopters
0 other operators
Author
filforce95 (imported from pull request #2010, open)
Base
d3ae8a6af0b76185e33f6dc2735679fa5c2be77c
Size
+1069 −325 in 21 files
Review
pending
Shared
2026-10-28
Id
1613f6ed336df674b7069b49

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/modify.rs crates/engine/src/cmd/options.rs crates/engine/src/cmd/trimextend.rs crates/engine/src/lib.rs crates/engine/src/prompt.rs crates/engine/src/select.rs crates/engine/src/sysvars.rs crates/engine/tests/it/main.rs crates/engine/tests/it/pickdrag.rs crates/engine/tests/it/selection_windows.rs crates/engine/tests/stretch_window_ortho.rs crates/ui-egui/src/canvas.rs crates/ui-egui/src/canvas/press.rs crates/ui-egui/src/options/dialog.rs crates/ui-egui/tests/it/main.rs crates/ui-egui/tests/it/pickdrag.rs crates/ui-egui/tests/it/press_first_clicks.rs crates/ui-egui/tests/it/rapid_clicks.rs crates/ui-egui/tests/it/selection_windows.rs docs/gaps.md docs/ui-parity.md

diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs--- a/crates/engine/src/cmd/modify.rs+++ b/crates/engine/src/cmd/modify.rs@@ -12,8 +12,6 @@ use super::helpers::{arc, circle, line, lwpoly, v3}; use super::machines::{SelOutcome, SelectPhase, number}; use super::trimextend::{self, TrimM}; use super::{CommandSpec, bad, bool_or, f64_or, f64_req, point_param, point_req, points_param, str_param, targets};--use crate::select::{SelectKind, SelectMode}; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};  pub fn specs() -> Vec<CommandSpec> {@@ -204,27 +202,10 @@ fn run_mirror(s: &mut Session, p: &Value) -> Result<Value> {     Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() })) } -/// An area whose vertices STRETCH moves: a window's box, or a polygon closed back to its first-/// point.-#[derive(Clone)]-enum StretchArea {-    Box(cadcraft_geom::Bounds2),-    Polygon(Vec<Vec2>),-}--impl StretchArea {-    fn contains(&self, p: Vec2) -> bool {-        match self {-            StretchArea::Box(bx) => bx.contains(p),-            StretchArea::Polygon(poly) => cadcraft_geom::point_in_polygon(poly, p),-        }-    }-}- /// Add to `hs` the visible multilines in `space` with a control vertex inside `area`. A crossing /// selection tests the drawn element lines, which sit away from the control vertices by the /// multiline's scale, so a small area around a vertex alone would miss them.-fn add_mline_vertex_hits(d: &cadcraft_doc::Drawing, space: &cadcraft_doc::Space, area: &StretchArea, hs: &mut Vec<Handle>) {+fn add_mline_vertex_hits(d: &cadcraft_doc::Drawing, space: &cadcraft_doc::Space, area: &crate::select::SelectArea, hs: &mut Vec<Handle>) {     let Some(store) = d.space(space) else { return };     for e in store.iter() {         if let EntityKind::MLine(m) = &e.kind@@ -238,7 +219,7 @@ fn add_mline_vertex_hits(d: &cadcraft_doc::Drawing, space: &cadcraft_doc::Space, }  /// Move the vertices of `e` that lie inside any of the areas `wins` by `d` (STRETCH).-fn stretch_entity(e: &mut Entity, wins: &[StretchArea], d: Vec2) {+fn stretch_entity(e: &mut Entity, wins: &[crate::select::SelectArea], d: Vec2) {     let inside = |p: Vec2| wins.iter().any(|a| a.contains(p));     let mv = |p: &mut cadcraft_geom::Vec3| {         if inside(p.xy()) {@@ -299,12 +280,12 @@ fn run_stretch(s: &mut Session, p: &Value) -> Result<Value> {         return Ok(json!({ "stretched": moved.len() }));     }     let (mut hs, area) = if let Some(poly) = listed("polygon", 3)? {-        (crate::select::select_polygon(s.doc()?, &space, &poly, true), StretchArea::Polygon(poly))+        (crate::select::select_polygon(s.doc()?, &space, &poly, true), crate::select::SelectArea::Polygon(poly))     } else {         let w = points_param(p, "window").ok_or_else(|| bad("stretch", "`window` [[x,y],[x,y]], `polygon` or `fence` is required"))?;         let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("stretch", "window needs 2 corners")) };         let bx = cadcraft_geom::Bounds2::new(*a, *b);-        (crate::select::select_window(s.doc()?, &space, bx, true), StretchArea::Box(bx))+        (crate::select::select_window(s.doc()?, &space, bx, true), crate::select::SelectArea::Box(bx))     };     add_mline_vertex_hits(s.doc()?, &space, &area, &mut hs);     hs.retain(|h| !super::curves::is_locked(s, *h));@@ -325,10 +306,12 @@ enum StretchPhase {     Second, } -/// STRETCH: "Select objects" repeats until Enter. Crossing windows (implied right to left, or after-/// `C`), windows (left to right, or after `W`) and polygons (`CP`, `WP`) select objects and mark-/// the area whose vertices move; objects picked one at a time (or by a fence, `F`, or-/// All/Last/Previous) move whole. Then the base point or Displacement, and the second point.+/// STRETCH: "Select objects" repeats until Enter. It is the session's own selection prompt, as in+/// every other command (#1116): crossing windows (implied right to left, or after `C`), windows+/// (left to right, or after `W`) and polygons (`CP`, `WP`) select objects and mark the area whose+/// vertices move ([`Session::take_pick_area`]); objects picked one at a time (or by a fence, `F`,+/// or All/Last/Previous) move whole. `R`/`A` switch to removing and back. Then the base point or+/// Displacement, and the second point. #[derive(Default)] struct StretchM {     phase: StretchPhase,@@ -336,12 +319,8 @@ struct StretchM {     /// Objects selected without a window: they move whole.     whole: Vec<Handle>,     /// The selection windows and polygons: vertices inside any of them move.-    windows: Vec<StretchArea>,+    windows: Vec<crate::select::SelectArea>,     removing: bool,-    /// `C`, `W`, `BOX`, `CP`, `WP` or `F` was typed: the next points are that kind of selection.-    mode: Option<SelectMode>,-    /// First corner of the window being drawn.-    corner: Option<Vec2>,     base: Option<Vec2>, } @@ -369,117 +348,26 @@ impl StretchM {         s.set_selection(self.objs.clone());     } -    /// Select with a typed mode that has its points. With too few (a fence of one point, a polygon-    /// of two) the mode is dropped.-    fn finish_mode(&mut self, s: &mut Session, m: SelectMode) -> Result<()> {-        let space = s.space();-        let Some(mut hs) = m.select(s.doc()?, &space) else { return Ok(()) };-        let crossing = match (m.kind, m.pts.first().zip(m.pts.get(1))) {-            (SelectKind::Crossing | SelectKind::CPolygon, _) => true,-            (SelectKind::Box, Some((a, b))) => b.x < a.x,-            _ => false,-        };-        let area = match (m.kind, m.pts.first().zip(m.pts.get(1))) {-            (SelectKind::WPolygon | SelectKind::CPolygon, _) => Some(StretchArea::Polygon(m.pts)),-            (SelectKind::Window | SelectKind::Crossing | SelectKind::Box, Some((a, b))) => {-                Some(StretchArea::Box(cadcraft_geom::Bounds2::new(*a, *b)))-            }-            // A fence marks no area: what it crosses moves whole.-            _ => None,-        };-        let whole = area.is_none();-        if let (Some(a), true, false) = (&area, crossing, self.removing) {-            add_mline_vertex_hits(s.doc()?, &space, a, &mut hs);-        }-        if let (Some(a), false) = (area, self.removing) {-            self.windows.push(a);-        }-        self.add(s, hs, whole);-        Ok(())-    }-     fn select(&mut self, s: &mut Session, i: Input) -> Result<Step> {-        if let Some(mut m) = self.mode.take() {-            match &i {-                Input::Point(p) => {-                    if m.push(*p) {-                        self.finish_mode(s, m)?;-                    } else {-                        self.mode = Some(m);-                    }-                    return Ok(Step::Continue);-                }-                Input::Text(t) | Input::Keyword(t) if matches!(t.trim().to_ascii_lowercase().as_str(), "u" | "undo") => {-                    m.pts.pop();-                    self.mode = Some(m);-                    return Ok(Step::Continue);-                }-                // Enter ends a fence or closes a polygon.-                Input::Enter => {-                    self.finish_mode(s, m)?;-                    return Ok(Step::Continue);-                }-                // Anything else leaves the mode and is read as usual.-                _ => {}-            }-        }         match i {-            Input::Point(p) => match self.corner.take() {-                Some(c) => {-                    let bx = cadcraft_geom::Bounds2::new(c, p);-                    let space = s.space();-                    let mut hs = crate::select::select_window(s.doc()?, &space, bx, p.x < c.x);-                    if !self.removing {-                        if p.x < c.x {-                            add_mline_vertex_hits(s.doc()?, &space, &StretchArea::Box(bx), &mut hs);-                        }-                        self.windows.push(StretchArea::Box(bx));-                    }-                    self.add(s, hs, false);-                }-                None => {-                    let aperture = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;-                    let space = s.space();-                    match crate::select::pick(s.doc()?, &space, p, aperture) {-                        Some(h) => self.add(s, vec![h], true),-                        None => self.corner = Some(p),-                    }-                }-            },-            Input::Pick(hs) => self.add(s, hs, true),-            Input::Text(t) | Input::Keyword(t) => {-                self.corner = None;-                let d = s.doc()?;-                let store = d.space(&s.space());-                let word = t.trim().to_ascii_lowercase();-                // `_C`, `_L`: the English name, as scripts write it.-                let word = word.strip_prefix('_').unwrap_or(&word).to_string();-                if let Some(m) = SelectMode::from_keyword(&word).filter(|m| m.kind != SelectKind::Group) {-                    self.mode = Some(m);-                    return Ok(Step::Continue);-                }-                let picked = match word.as_str() {-                    "r" | "remove" => {-                        self.removing = true;-                        None-                    }-                    "a" | "add" => {-                        self.removing = false;-                        None+            // Whatever selected the objects; a window or polygon marks where vertices move.+            Input::Pick(mut hs) => {+                let area = s.take_pick_area();+                let whole = area.is_none();+                if let (Some(a), false) = (area, self.removing) {+                    // A crossing tests a multiline's drawn lines; a control vertex inside counts too.+                    if a.crossing {+                        add_mline_vertex_hits(s.doc()?, &s.space(), &a.area, &mut hs);                     }-                    "all" => Some(store.map(|st| st.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()).unwrap_or_default()),-                    "l" | "last" => Some(store.and_then(|st| st.last()).map(|e| vec![e.handle]).unwrap_or_default()),-                    "p" | "previous" => Some(s.state()?.previous_selection.clone()),-                    _ => {-                        s.echo("*Invalid selection*");-                        None-                    }-                };-                if let Some(hs) = picked {-                    self.add(s, hs, true);+                    self.windows.push(a.area);                 }+                self.add(s, hs, whole);             }-            Input::Enter if self.corner.is_some() => self.corner = None,+            Input::Text(t) | Input::Keyword(t) => match t.trim().to_ascii_lowercase().as_str() {+                "r" | "remove" => self.removing = true,+                "a" | "add" => self.removing = false,+                _ => s.echo("*Invalid selection*"),+            },             Input::Enter if self.objs.is_empty() => return Ok(Step::Done),             Input::Enter => {                 s.remember_selection(&self.objs);@@ -515,15 +403,11 @@ impl Interactive for StretchM {         Ok(Step::Continue)     }     fn prompt(&self, _s: &Session) -> Prompt {-        if let (StretchPhase::Select, Some(m)) = (self.phase, &self.mode) {-            return m.prompt();-        }-        match (self.phase, self.corner) {-            (StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).corner(c),-            (StretchPhase::Select, None) => Prompt::new(if self.removing { "Remove objects" } else { "Select objects" }, Accept::POINT),-            (StretchPhase::Base, _) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement"),-            (StretchPhase::Displacement, _) => Prompt::new("Specify displacement", Accept::POINT).default("0,0"),-            (StretchPhase::Second, _) => Prompt::new("Specify second point or <use first point as displacement>", Accept::POINT).base_opt(self.base),+        match self.phase {+            StretchPhase::Select => Prompt::new(if self.removing { "Remove objects" } else { "Select objects" }, Accept::SELECT),+            StretchPhase::Base => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement"),+            StretchPhase::Displacement => Prompt::new("Specify displacement", Accept::POINT).default("0,0"),+            StretchPhase::Second => Prompt::new("Specify second point or <use first point as displacement>", Accept::POINT).base_opt(self.base),         }     }     fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {@@ -550,26 +434,8 @@ impl Interactive for StretchM {         }     }     fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {-        let rect = |a: Vec2| {-            let (b, d) = (Vec2::new(c.x, a.y), Vec2::new(a.x, c.y));-            vec![line(a, b), line(b, c), line(c, d), line(d, a)]-        };-        if let (StretchPhase::Select, Some(m)) = (self.phase, &self.mode) {-            let Some(first) = m.pts.first().copied() else { return Vec::new() };-            if !matches!(m.kind, SelectKind::Fence | SelectKind::WPolygon | SelectKind::CPolygon) {-                return rect(first);-            }-            // The fence or polygon so far, on to the cursor; a polygon closes back to its start.-            let mut out: Vec<EntityKind> = m.pts.windows(2).filter_map(|w| Some(line(*w.first()?, *w.get(1)?))).collect();-            out.extend(m.pts.last().map(|l| line(*l, c)));-            if m.kind != SelectKind::Fence && m.pts.len() >= 2 {-                out.push(line(c, first));-            }-            return out;-        }-        match (self.phase, self.corner, self.base) {-            (StretchPhase::Select, Some(a), _) => rect(a),-            (StretchPhase::Second, _, Some(base)) => {+        match (self.phase, self.base) {+            (StretchPhase::Second, Some(base)) => {                 let Ok(doc) = s.doc() else { return Vec::new() };                 let m = Mat3::translate(c - base);                 let mut out: Vec<EntityKind> = selfdiff --git a/crates/engine/src/cmd/options.rs b/crates/engine/src/cmd/options.rs--- a/crates/engine/src/cmd/options.rs+++ b/crates/engine/src/cmd/options.rs@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {         CommandSpec::new("options", "Options...", run_options)             .menu(&["Tools", "Options..."])             .alias(&["op", "preferences"])-            .params("{reset?: bool, OSMODE?, PICKBOX?, APERTURE?, GRIPSIZE?, CURSORSIZE?, PICKFIRST?, PICKADD?, PICKDRAG?: 0 (two clicks) | 2 (clicks or drag), …: any profile variable}")+            .params("{reset?: bool, OSMODE?, PICKBOX?, APERTURE?, GRIPSIZE?, CURSORSIZE?, PICKFIRST?, PICKADD?, PICKDRAG?: 0 (two clicks) | 1 (drag) | 2 (clicks or drag), …: any profile variable}")             .enabled(always)             .noundo(),     ]diff --git a/crates/engine/src/cmd/trimextend.rs b/crates/engine/src/cmd/trimextend.rs--- a/crates/engine/src/cmd/trimextend.rs+++ b/crates/engine/src/cmd/trimextend.rs@@ -8,7 +8,7 @@ use cadcraft_doc::{Drawing, EntityKind, Handle}; use cadcraft_geom::{Bounds2, Line, Vec2, line_line}; use serde_json::{Value, json}; -use super::helpers::{line, lwpoly, rect_vertices};+use super::helpers::line; use super::modify::{Edges, extend, trim}; use super::{bad, point_param, points_param}; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};@@ -357,7 +357,8 @@ impl Interactive for TrimM {                 Some(l) => Prompt::new("Specify next fence point", Accept::POINT).kw(&["Undo"]).base(*l),             },             Stage::Crossing(None) => Prompt::new("Specify first corner", Accept::POINT),-            Stage::Crossing(Some(_)) => Prompt::new("Specify opposite corner", Accept::POINT),+            // A selection window like any other: drawn as a crossing, closed by a drag (PICKDRAG).+            Stage::Crossing(Some(a)) => Prompt::new("Specify opposite corner", Accept::POINT).window(*a, Some(true)),             Stage::Mode => {                 let msg = if self.extend { "Enter an extend mode option" } else { "Enter a trim mode option" };                 Prompt::new(msg, Accept::TEXT).kw(&["Quick", "Standard"]).default(if quick_mode(s) { "Quick" } else { "Standard" })@@ -525,7 +526,6 @@ impl Interactive for TrimM {                 .windows(2)                 .filter_map(|w| Some(line(*w.first()?, *w.get(1)?)))                 .collect(),-            Stage::Crossing(Some(a)) => vec![lwpoly(rect_vertices(*a, c), true)],             _ => Vec::new(),         }     }diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -47,7 +47,7 @@ pub use cadcraft_geom as geom; pub use cadcraft_render as render; pub use cmd::hatch::{HatchPicks, PatternChoice, PickMode, pattern_choices}; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};-pub use prompt::{Accept, Drag, Input, Interactive, Picker, Prompt, Step};+pub use prompt::{Accept, Drag, Input, Interactive, Picker, Prompt, SelectWindow, Step};  #[derive(Debug, thiserror::Error)] pub enum EngineError {@@ -408,9 +408,10 @@ pub struct Settings {     /// FILEDIA: kept for routines that switch file dialogs off around a script; no command reads it yet.     pub filedia: bool,     pub pickadd: bool,-    /// PICKDRAG: false = two-click windows (0), true = clicks or dragging (2).-    /// Drag-only mode (1) is not supported yet.-    pub pickdrag: bool,+    /// PICKDRAG: how a selection window's second corner is given: 0 by a second click, 1 where+    /// the button that set the first corner comes up (drag), 2 either way (a drag held past the+    /// press tolerance closes it on release, a click leaves it for the next click).+    pub pickdrag: u8,     /// PICKSTYLE: bit 1 picks whole selectable groups ([`cmd::groups`]), bit 2 associative hatches     /// with their boundaries (stored, not used yet).     pub pickstyle: u8,@@ -502,7 +503,7 @@ impl Default for Settings {             cmdecho: true,             filedia: true,             pickadd: true,-            pickdrag: true,+            pickdrag: 2,             pickstyle: 1,             gripsize: 5.0,             cursorsize: 5.0,@@ -692,6 +693,9 @@ pub struct Session {     pub clipboard: Vec<Entity>,     pub clipboard_base: Vec2,     pub pending_window: Option<PendingWindow>,+    /// The window or polygon the last pick at a command's "Select objects" was made with (`None`+    /// for single picks, fences, All, Last…); see [`Session::take_pick_area`].+    pick_area: Option<select::PickArea>,     /// 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.@@ -751,6 +755,7 @@ impl Session {             clipboard: Vec::new(),             clipboard_base: Vec2::ZERO,             pending_window: None,+            pick_area: None,             select_mode: None,             select_single: false,             untitled_counter: 0,@@ -1148,6 +1153,7 @@ impl Session {         if !prompt.accept.select {             return Ok(Some(input));         }+        self.pick_area = None;         if self.select_mode.is_some() {             return self.select_mode_input(input);         }@@ -1183,7 +1189,9 @@ impl Session {                 let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;                 if let Some(pw) = self.pending_window.take() {                     let crossing = p.x < pw.corner.x;-                    let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);+                    let bx = Bounds2::new(pw.corner, p);+                    let hs = select::select_window(self.doc()?, &space, bx, crossing);+                    self.pick_area = Some(select::PickArea { area: select::SelectArea::Box(bx), crossing });                     return Ok(Some(Input::Pick(cmd::groups::expand(self, hs))));                 }                 match select::pick(self.doc()?, &space, p, ap) {@@ -1243,6 +1251,7 @@ impl Session {         match input {             Input::Point(p) => {                 if mode.push(p) {+                    self.pick_area = mode.area();                     return Ok(mode.select(self.doc()?, &self.space()).map(Input::Pick));                 }             }@@ -1250,7 +1259,10 @@ impl Session {                 mode.pts.pop();             }             // Enter ends a fence or polygon; with too few points the mode is dropped.-            Input::Enter => return Ok(mode.select(self.doc()?, &self.space()).map(Input::Pick)),+            Input::Enter => {+                self.pick_area = mode.area();+                return Ok(mode.select(self.doc()?, &self.space()).map(Input::Pick));+            }             Input::Cancel | Input::Pick(_) => return Ok(Some(input)),             _ => self.echo("Invalid point."),         }@@ -1476,6 +1488,8 @@ impl Session {         let mut common = self.current_common();         let (kinds, mut out) = match (self.lisp_preview(cursor), &self.running) {             (Some(k), _) => (k, Vec::new()),+            // A typed fence or polygon at any command's "Select objects" shows its lines so far.+            (None, Some(_)) if let Some(m) = &self.select_mode => (m.preview(cursor), Vec::new()),             (None, Some(r)) => (r.machine.preview(self, cursor), r.machine.preview_objects(self, cursor)),             (None, None) => (Vec::new(), Vec::new()),         };@@ -1705,7 +1719,7 @@ impl Session {     /// command running, or a command asking for objects. Returns whether a window was opened.     pub fn begin_window(&mut self, corner: Vec2) -> bool {         let during_command = self.running.is_some();-        if !self.settings.pickdrag || !corner.is_finite() || (during_command && !self.current_prompt().is_some_and(|p| p.accept.select)) {+        if self.settings.pickdrag == 0 || !corner.is_finite() || (during_command && !self.current_prompt().is_some_and(|p| p.accept.select)) {             return false;         }         self.pending_window = Some(PendingWindow { corner, during_command });@@ -1757,6 +1771,24 @@ impl Session {      /// Whether a click at `p` selects objects by picking: no command running, or a command at     /// "Select objects" with no selection window or typed mode under way.+    /// The window or polygon the pick just handed to the running command was made with, taken+    /// so it is read once: STRETCH moves the vertices inside it (#1116). `None` for a single+    /// pick, a fence, All, Last or Previous.+    pub fn take_pick_area(&mut self) -> Option<select::PickArea> {+        self.pick_area.take()+    }++    /// The selection window waiting for its opposite corner: the one a click on empty space+    /// opened (with or without a command), or one a command opened itself and asks for the corner+    /// of ([`Prompt::window`]: TRIM's Crossing, a typed `W` or `C`). The UI draws them all alike+    /// and lets a drag close them (PICKDRAG).+    pub fn selection_window(&self) -> Option<SelectWindow> {+        match self.pending_window {+            Some(pw) => Some(SelectWindow { corner: pw.corner, crossing: None }),+            None => self.current_prompt().and_then(|p| p.window),+        }+    }+     pub fn click_picks(&self) -> bool {         self.pending_window.is_none()             && match self.running.as_ref() {diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs--- a/crates/engine/src/prompt.rs+++ b/crates/engine/src/prompt.rs@@ -66,6 +66,27 @@ pub struct Prompt {     /// the routine writes, so it cannot be read for the word "Select" (#1598).     #[serde(skip_serializing_if = "std::ops::Not::not")]     pub picks_one: bool,+    /// The prompt asks for the opposite corner of a selection window ([`Prompt::window`]).+    #[serde(skip_serializing_if = "Option::is_none")]+    pub window: Option<SelectWindow>,+}++/// A selection window waiting for its opposite corner ([`Prompt::window`],+/// [`crate::Session::selection_window`]).+#[derive(Clone, Copy, Debug, PartialEq, Serialize)]+#[serde(rename_all = "camelCase")]+pub struct SelectWindow {+    pub corner: Vec2,+    /// `Some(true)`: always a crossing (TRIM's Crossing, a typed `C`), `Some(false)`: always a+    /// window (a typed `W`), `None`: a window to the right of the corner, a crossing to its left.+    pub crossing: Option<bool>,+}++impl SelectWindow {+    /// Whether the window from the corner to `p` selects as a crossing.+    pub fn crossing_to(&self, p: Vec2) -> bool {+        self.crossing.unwrap_or(p.x < self.corner.x)+    } }  /// Parse `@distance<angle` (relative polar), returning `(distance, angle in radians)`.@@ -118,6 +139,15 @@ impl Prompt {         self.corner = true;         self     }+    /// Asks for the opposite corner of a selection window from `corner` (the `window` field):+    /// the UI draws it as a selection area, and a drag that opened it closes it where the button+    /// comes up (PICKDRAG). It picks objects, so object snaps and Dynamic Input stay off, and the+    /// base is a window corner ([`Prompt::corner`]), so ortho never bends it onto an axis (#1556).+    pub fn window(mut self, corner: Vec2, crossing: Option<bool>) -> Self {+        self = self.corner(corner);+        self.window = Some(SelectWindow { corner, crossing });+        self+    }     /// Asks for a distance from the base (the `distance` field).     pub fn distance(mut self) -> Self {         self.distance = true;@@ -133,13 +163,13 @@ impl Prompt {         self     }     /// The prompt picks objects: a "Select objects" prompt, one that says it picks a single object-    /// ([`Prompt::picks_one`]), or a point prompt whose click selects one ("Select object to+    /// ([`Prompt::picks_one`]), a selection window's opposite corner ([`Prompt::window`]), or a point prompt whose click selects one ("Select object to     /// offset", "Select first line"…, the commands that pick a single object themselves). The UI     /// shows the pick box there. The wording is only read where the engine wrote it: a routine     /// writes its own message (`raw`), which may open with "Select" and still ask for a point,     /// and says through `picks_one` when it picks instead (#1635 review).     pub fn picks_objects(&self) -> bool {-        self.picks_one || self.accept.select || (self.accept.point && !self.raw && self.message.starts_with("Select"))+        self.picks_one || self.accept.select || self.window.is_some() || (self.accept.point && !self.raw && self.message.starts_with("Select"))     }     /// "Specify next point or [Undo/Close] <default>:".     pub fn display(&self) -> String {diff --git a/crates/engine/src/select.rs b/crates/engine/src/select.rs--- a/crates/engine/src/select.rs+++ b/crates/engine/src/select.rs@@ -376,6 +376,32 @@ pub fn select_group(d: &Drawing, space: &Space, name: &str) -> Option<Vec<Handle     Some(g.members.iter().copied().filter(|h| store.get(*h).is_some_and(|e| d.is_visible(e))).collect()) } +/// The area a selection was made with: a window's box, or a polygon closed back to its first+/// point. STRETCH moves the vertices inside it ([`crate::Session::take_pick_area`]).+#[derive(Clone, Debug, PartialEq)]+pub enum SelectArea {+    Box(Bounds2),+    Polygon(Vec<Vec2>),+}++impl SelectArea {+    pub fn contains(&self, p: Vec2) -> bool {+        match self {+            SelectArea::Box(bx) => bx.contains(p),+            SelectArea::Polygon(poly) => point_in_polygon(poly, p),+        }+    }+}++/// The area a pick at a command's "Select objects" was made with, and whether it selected as a+/// crossing (objects inside or crossing it) or a window (objects inside it):+/// [`crate::Session::take_pick_area`].+#[derive(Clone, Debug, PartialEq)]+pub struct PickArea {+    pub area: SelectArea,+    pub crossing: bool,+}+ /// A selection mode typed at "Select objects", collecting its points before it selects. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SelectKind {@@ -434,7 +460,14 @@ impl SelectMode {         };         match last {             None => Prompt::new(first, Accept::POINT),-            Some(p) if self.is_box() => Prompt::new(next, Accept::POINT).corner(p),+            Some(p) if self.is_box() => Prompt::new(next, Accept::POINT).window(+                p,+                match self.kind {+                    SelectKind::Window => Some(false),+                    SelectKind::Crossing => Some(true),+                    _ => None,+                },+            ),             Some(p) => Prompt::new(next, Accept::POINT).kw(&["Undo"]).base(p),         }     }@@ -445,6 +478,39 @@ impl SelectMode {         }         self.is_box() && self.pts.len() >= 2     }+    /// The fence or polygon so far, on to the cursor `c`; a polygon closes back to its start. A+    /// window's corners are drawn by the UI as a selection area ([`Prompt::window`]).+    pub fn preview(&self, c: Vec2) -> Vec<EntityKind> {+        use crate::cmd::helpers::line;+        let Some(first) = self.pts.first().copied() else { return Vec::new() };+        if !matches!(self.kind, SelectKind::Fence | SelectKind::WPolygon | SelectKind::CPolygon) {+            return Vec::new();+        }+        let mut out: Vec<EntityKind> = self.pts.windows(2).filter_map(|w| Some(line(*w.first()?, *w.get(1)?))).collect();+        out.extend(self.pts.last().map(|l| line(*l, c)));+        if self.kind != SelectKind::Fence && self.pts.len() >= 2 {+            out.push(line(c, first));+        }+        out+    }+    /// The area the mode selects with once it has its points; a fence or a group marks none.+    pub fn area(&self) -> Option<PickArea> {+        let (area, crossing) = match (self.kind, self.pts.as_slice()) {+            (SelectKind::Window | SelectKind::Crossing | SelectKind::Box, [a, b, ..]) => {+                let crossing = match self.kind {+                    SelectKind::Crossing => true,+                    SelectKind::Box => b.x < a.x,+                    _ => false,+                };+                (SelectArea::Box(Bounds2::new(*a, *b)), crossing)+            }+            (SelectKind::WPolygon | SelectKind::CPolygon, pts) if pts.len() >= 3 => {+                (SelectArea::Polygon(pts.to_vec()), self.kind == SelectKind::CPolygon)+            }+            _ => return None,+        };+        Some(PickArea { area, crossing })+    }     /// The objects the mode selects, or `None` while it has too few points.     pub fn select(&self, d: &Drawing, space: &Space) -> Option<Vec<Handle>> {         match (self.kind, self.pts.as_slice()) {diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs--- a/crates/engine/src/sysvars.rs+++ b/crates/engine/src/sysvars.rs@@ -135,7 +135,7 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {         "CMDECHO" => json!(i32::from(st.cmdecho)),         "FILEDIA" => json!(i32::from(st.filedia)),         "PICKADD" => json!(i32::from(st.pickadd)),-        "PICKDRAG" => json!(i32::from(st.pickdrag) * 2),+        "PICKDRAG" => json!(st.pickdrag),         "PICKSTYLE" => json!(st.pickstyle),         "GRIPSIZE" => json!(st.gripsize),         "CURSORSIZE" => json!(st.cursorsize),@@ -353,7 +353,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {         "CMDECHO" => st.cmdecho = small(v, 1).ok_or_else(bad)? == 1,         "FILEDIA" => st.filedia = small(v, 1).ok_or_else(bad)? == 1,         "PICKADD" => st.pickadd = as_bool(v).ok_or_else(bad)?,-        "PICKDRAG" => st.pickdrag = as_i(v).filter(|b| matches!(b, 0 | 2)).map(|b| b == 2).ok_or_else(bad)?,+        "PICKDRAG" => st.pickdrag = small(v, 2).ok_or_else(bad)?,         "PICKSTYLE" => st.pickstyle = as_i(v).filter(|b| (0..=3).contains(b)).and_then(|b| u8::try_from(b).ok()).ok_or_else(bad)?,         "GRIPSIZE" => st.gripsize = as_f64(v).ok_or_else(bad)?.clamp(1.0, 255.0),         "CURSORSIZE" => st.cursorsize = as_f64(v).ok_or_else(bad)?.clamp(1.0, 100.0),diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs--- a/crates/engine/tests/it/main.rs+++ b/crates/engine/tests/it/main.rs@@ -110,6 +110,7 @@ mod select_similar; mod select_underscore_keywords; mod selection_cycling; mod selection_modes;+mod selection_windows; mod setvar_cecolor; mod setvar_coverage; mod shapes;diff --git a/crates/engine/tests/it/pickdrag.rs b/crates/engine/tests/it/pickdrag.rs--- a/crates/engine/tests/it/pickdrag.rs+++ b/crates/engine/tests/it/pickdrag.rs@@ -11,15 +11,20 @@ fn pickdrag_is_saved_reset_and_validated() {     let saved = options::profile(&s);     let mut restored = Session::new();     options::restore(&mut restored, &saved);-    assert!(!restored.settings.pickdrag);-    for value in [json!(1), json!(-1), json!(3), json!("bad"), json!(null), json!([0]), json!(1e300)] {+    assert_eq!(restored.settings.pickdrag, 0);+    for value in [json!(-1), json!(3), json!("bad"), json!(null), json!([0]), json!(1e300)] {         assert!(s.execute("options", &json!({"PICKDRAG":value})).is_err());         assert_eq!(options::profile(&s), saved);     }     s.execute("options", &json!({"reset":true})).unwrap();-    assert!(s.settings.pickdrag);-    options::restore(&mut restored, &json!({"PICKDRAG":1}));-    assert!(!restored.settings.pickdrag, "unsupported saved modes are ignored");+    assert_eq!(s.settings.pickdrag, 2);+    // Drag-only (1) is a mode like the others.+    s.execute("options", &json!({"PICKDRAG":1})).unwrap();+    assert_eq!(options::profile(&s)["PICKDRAG"], 1);+    options::restore(&mut restored, &options::profile(&s));+    assert_eq!(restored.settings.pickdrag, 1);+    options::restore(&mut restored, &json!({"PICKDRAG":7}));+    assert_eq!(restored.settings.pickdrag, 1, "unsupported saved modes are ignored"); }  #[test]diff --git a/crates/engine/tests/it/selection_windows.rs b/crates/engine/tests/it/selection_windows.rsnew file mode 100644--- /dev/null+++ b/crates/engine/tests/it/selection_windows.rs@@ -0,0 +1,187 @@+//! One selection for every command (#1116, #1001 follow-up): STRETCH selects through the+//! session's "Select objects" like MOVE or ERASE, and every selection window waiting for its+//! opposite corner, whoever opened it, is reported alike (`Session::selection_window`), so the UI+//! draws it and lets a drag close it the same way everywhere.++use cadcraft_engine::Session;+use cadcraft_engine::doc::{EntityKind, Handle};+use cadcraft_engine::geom::Vec2;+use cadcraft_engine::prompt::Input;+use cadcraft_engine::select::{PickArea, SelectArea};+use serde_json::json;++fn session() -> Session {+    let mut s = Session::new();+    s.viewport_px = (1000.0, 1000.0);+    s.execute("view.set", &json!({"center": [10, 5], "height": 40})).unwrap();+    s.settings.osmode = 0;+    s+}++fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle {+    let h = s.execute("line", &json!({ "points": [a, b] })).unwrap()["handles"][0].as_str().unwrap().to_string();+    Handle::parse_hex(&h).unwrap()+}++fn ends(s: &Session, h: Handle) -> (Vec2, Vec2) {+    match &s.doc().unwrap().entity(h).unwrap().kind {+        EntityKind::Line(l) => (l.a.xy(), l.b.xy()),+        _ => panic!("not a line"),+    }+}++fn point(s: &mut Session, x: f64, y: f64) {+    s.input(Input::Point(Vec2::new(x, y))).unwrap();+}++#[test]+fn stretch_selects_at_the_sessions_select_objects_prompt() {+    let mut s = session();+    let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);+    s.start("stretch").unwrap();+    let p = s.current_prompt().unwrap();+    assert!(p.accept.select, "STRETCH asks for objects like every other command: {p:?}");+    // A click on empty space opens the session's window; right to left it is a crossing.+    point(&mut s, 12.0, 2.0);+    let w = s.selection_window().expect("a window waits for its opposite corner");+    assert_eq!(w.corner, Vec2::new(12.0, 2.0));+    assert!(w.crossing_to(Vec2::new(8.0, -2.0)));+    point(&mut s, 8.0, -2.0);+    assert!(s.selection_window().is_none());+    assert_eq!(s.selection(), vec![a]);+    s.input(Input::Enter).unwrap();+    point(&mut s, 0.0, 0.0);+    point(&mut s, 5.0, 0.0);+    assert!(s.running.is_none(), "{}", s.prompt_text());+    assert_eq!(ends(&s, a), (Vec2::new(0.0, 0.0), Vec2::new(15.0, 0.0)), "only the end inside the crossing moves");+}++#[test]+fn stretch_moves_picked_objects_whole_and_removes_with_r() {+    let mut s = session();+    let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);+    let b = line(&mut s, [0.0, 5.0], [10.0, 5.0]);+    s.start("stretch").unwrap();+    // A pick, then a crossing over both right ends, then remove the second line with a pick.+    point(&mut s, 5.0, 0.0);+    point(&mut s, 12.0, 7.0);+    point(&mut s, 8.0, -2.0);+    s.input(Input::Text("R".into())).unwrap();+    assert_eq!(s.current_prompt().unwrap().message, "Remove objects");+    point(&mut s, 5.0, 5.0);+    s.input(Input::Enter).unwrap();+    point(&mut s, 0.0, 0.0);+    point(&mut s, 0.0, 3.0);+    assert!(s.running.is_none(), "{}", s.prompt_text());+    // Picked first, so it moves whole even though the crossing also took it.+    assert_eq!(ends(&s, a), (Vec2::new(0.0, 3.0), Vec2::new(10.0, 3.0)));+    assert_eq!(ends(&s, b), (Vec2::new(0.0, 5.0), Vec2::new(10.0, 5.0)), "removed");+}++#[test]+fn the_area_a_pick_was_made_with_is_read_once() {+    let mut s = session();+    line(&mut s, [0.0, 0.0], [10.0, 0.0]);+    s.start("erase").unwrap();+    point(&mut s, 5.0, 0.0);+    assert_eq!(s.take_pick_area(), None, "a single pick marks no area");+    point(&mut s, -2.0, -2.0);+    point(&mut s, 12.0, 2.0);+    let bx = match s.take_pick_area() {+        Some(PickArea { area: SelectArea::Box(bx), crossing: false }) => bx,+        other => panic!("left to right is a window: {other:?}"),+    };+    assert!(bx.contains(Vec2::new(0.0, 0.0)) && bx.contains(Vec2::new(10.0, 0.0)));+    assert_eq!(s.take_pick_area(), None);+    s.input(Input::Text("CP".into())).unwrap();+    for (x, y) in [(-1.0, -1.0), (11.0, -1.0), (5.0, 3.0)] {+        point(&mut s, x, y);+    }+    s.input(Input::Enter).unwrap();+    assert!(matches!(s.take_pick_area(), Some(PickArea { area: SelectArea::Polygon(p), crossing: true }) if p.len() == 3));+}++#[test]+fn typed_window_and_crossing_corners_are_selection_windows() {+    let mut s = session();+    s.start("erase").unwrap();+    for (kw, crossing) in [("W", Some(false)), ("C", Some(true)), ("BOX", None)] {+        s.input(Input::Text(kw.into())).unwrap();+        assert!(s.selection_window().is_none(), "no corner yet");+        point(&mut s, 0.0, 0.0);+        let w = s.selection_window().expect("the opposite corner is a selection window");+        assert_eq!((w.corner, w.crossing), (Vec2::ZERO, crossing), "{kw}");+        let p = s.current_prompt().unwrap();+        assert!(!cadcraft_engine::pointmod::accepts(&p), "{kw}: a window corner picks objects, no object snaps");+        point(&mut s, 3.0, 3.0);+        assert!(s.selection_window().is_none());+    }+}++#[test]+fn trims_crossing_window_is_a_selection_window() {+    let mut s = session();+    line(&mut s, [0.0, 0.0], [20.0, 0.0]);+    line(&mut s, [10.0, -5.0], [10.0, 5.0]);+    s.start("trim").unwrap();+    // A click on empty space at "Select object to trim" opens TRIM's crossing window.+    point(&mut s, 15.0, 2.0);+    let w = s.selection_window().expect("TRIM's crossing waits for its opposite corner");+    assert_eq!((w.corner, w.crossing), (Vec2::new(15.0, 2.0), Some(true)), "always a crossing");+    assert!(!cadcraft_engine::pointmod::accepts(&s.current_prompt().unwrap()), "no object snaps at the corner");+    point(&mut s, 18.0, -2.0);+    assert!(s.selection_window().is_none());+}++#[test]+fn a_typed_fence_shows_its_lines_at_any_select_objects() {+    let mut s = session();+    s.start("erase").unwrap();+    s.input(Input::Text("F".into())).unwrap();+    point(&mut s, 0.0, 0.0);+    point(&mut s, 5.0, 0.0);+    let pv = s.preview(Vec2::new(5.0, 5.0));+    assert_eq!(pv.len(), 2, "the fence so far and on to the cursor: {pv:?}");+}++/// A crossing made at STRETCH's "Select objects" moves only the multiline vertex inside it (#1739),+/// whether clicked or typed (`CP`).+#[test]+fn a_stretch_crossing_takes_a_multiline_by_its_vertex() {+    let vertices = |s: &Session| -> Vec<(f64, f64)> {+        s.doc()+            .unwrap()+            .model+            .iter()+            .find_map(|e| match &e.kind {+                EntityKind::MLine(m) => Some(m.vertices.iter().map(|v| (v.p.x, v.p.y)).collect()),+                _ => None,+            })+            .unwrap()+    };+    let mut s = Session::new();+    s.viewport_px = (1000.0, 1000.0);+    // Zoomed in so the clicks 5 from the vertex are on empty space, not a pick of the multiline.+    s.execute("view.set", &json!({"center": [3000, 300], "height": 500})).unwrap();+    s.settings.osmode = 0;+    // The element lines run 100 away from the middle vertex at (3000, 0).+    s.execute("mline", &json!({ "points": [[0, 0], [3000, 0], [3000, 2000]], "scale": 200 })).unwrap();+    s.start("stretch").unwrap();+    point(&mut s, 3005.0, 5.0);+    point(&mut s, 2995.0, -5.0);+    s.input(Input::Enter).unwrap();+    point(&mut s, 0.0, 0.0);+    point(&mut s, 0.0, 610.0);+    assert!(s.running.is_none(), "{}", s.prompt_text());+    assert_eq!(vertices(&s), vec![(0.0, 0.0), (3000.0, 610.0), (3000.0, 2000.0)]);+    s.start("stretch").unwrap();+    s.input(Input::Text("CP".into())).unwrap();+    for (x, y) in [(2995.0, 605.0), (3005.0, 605.0), (3000.0, 615.0)] {+        point(&mut s, x, y);+    }+    s.input(Input::Enter).unwrap();+    s.input(Input::Enter).unwrap();+    point(&mut s, 0.0, 0.0);+    point(&mut s, 0.0, -610.0);+    assert_eq!(vertices(&s), vec![(0.0, 0.0), (3000.0, 0.0), (3000.0, 2000.0)]);+}diff --git a/crates/engine/tests/stretch_window_ortho.rs b/crates/engine/tests/stretch_window_ortho.rs--- a/crates/engine/tests/stretch_window_ortho.rs+++ b/crates/engine/tests/stretch_window_ortho.rs@@ -27,7 +27,8 @@ fn ortho_session() -> Session {  /// Pick the window's opposite corner at `to`, stretch by 5,0 and return the extents. fn stretch_from_corner(mut s: Session, to: Vec2) -> (Vec2, Vec2) {-    assert_eq!(s.current_prompt().unwrap().message, "Specify opposite corner");+    // The session's window (implied) or the typed mode's (`C`) waits for its opposite corner.+    assert!(s.selection_window().is_some(), "a selection window waits at {:?}", s.current_prompt().map(|p| p.message));     let c = hover(&mut s, to);     assert!(c.near(to, 1e-9), "the corner is where the cursor is: {c:?}");     s.input(Input::Point(c)).unwrap();diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs--- a/crates/ui-egui/src/canvas.rs+++ b/crates/ui-egui/src/canvas.rs@@ -119,8 +119,8 @@ pub struct CanvasState {     hover_at: Option<Pos2>,     /// Constraint glyphs for the parametric overlay (cached per drawing revision).     pub param: crate::parametric::Cache,-    /// A selection window opened by a press-and-drag, to close where the button is let go.-    drag_window: bool,+    /// The primary-button press being held, when it was acted on (see [`press`]).+    press: Option<press::Press>,     /// The right-click that opened the shortcut menu held Shift or Ctrl: it is the object snap     /// menu ([`crate::context_menu`]).     pub snap_menu: bool,@@ -1524,12 +1524,12 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {         if !in_active {             app.canvas.hot_grips.clear();             app.session.pending_window = None;-            app.canvas.drag_window = false;+            app.canvas.press = None;             if app.run("mspace", serde_json::json!({"at": [pp.x, pp.y]})).is_err() && active_vp.is_some() {                 let _ = app.run("pspace", serde_json::json!({}));             }             app.session.pending_window = None;-            app.canvas.drag_window = false;+            app.canvas.press = None;             app.canvas.list = None;             return;         }@@ -1558,77 +1558,89 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {         hatch_dialog = true;     } -    // While a command asks for a point, every click places one, however fast the drafting (#815).-    // egui drops a click whose button comes up a few pixels from where it went down (a drag to it),-    // and eframe hands a slow frame's clicks over together, which egui reports as one.-    let point_prompt = app.session.current_prompt().is_some_and(|p| cadcraft_engine::pointmod::accepts(&p))-        && app.session.pending_window.is_none()-        && !app.session.realtime_zoom_active();-    let pressed_primary = pressed_primary || (point_prompt && resp.drag_stopped_by(egui::PointerButton::Primary));-    if point_prompt && inside && pressed_primary && !middle_down && app.cmd.options.is_none() && app.cmd.buffer.is_empty() {-        let releases: Vec<egui::Pos2> = ui.input(|i| {-            i.events-                .iter()-                .filter_map(|e| match e {-                    egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, .. } => Some(*pos),-                    _ => None,-                })-                .collect()-        });-        // The last one is placed below, at the cursor, like any click.-        if let Some((_, earlier)) = releases.split_last() {-            for pos in earlier.iter().filter(|p| rect.contains(**p)) {-                let w = effective_point(app, xf.to_world(*pos), &xf, ui.ctx());-                let result = match app.canvas.snap {-                    Some(hit) => app.session.input_snapped(hit),-                    None => app.session.input(Input::Point(w)),+    // Clicks count when the button goes down, where it went down, however fast the mouse moves+    // (#1001, #815): each press among this frame's events places its point, picks its object or+    // sets a selection window's corner at once. What the pointer does while the button is held+    // only decides whether the press becomes a drag ([`press`]): a drag closes the window its+    // press opened where the button comes up; after a click the window waits for the next press.+    // Presses on grips (idle) and while ZOOM Realtime runs are left to the clicks further down.+    // Selection cycling's list closes when the prompt, the drawing or the view changes.+    let key: CycleKey = (+        app.session.running.as_ref().map(|r| r.id.clone()),+        app.session.current_prompt().map(|p| p.message),+        app.session.state().map_or(0, |st| st.uid),+        app.session.state().map_or(0, |st| st.revision),+        xf.center,+        xf.scale,+    );+    if app.canvas.cycle_key.as_ref() != Some(&key) {+        app.canvas.cycle = None;+        app.canvas.hover_cycle = None;+        app.canvas.cycle_key = Some(key);+    }+    let (steps, now, primary_down) = ui.input(|i| (press::steps(&i.events), i.time, i.pointer.primary_down()));+    let mut click_used = false;+    for step in steps {+        match step {+            press::Step::Down(pos, m) => {+                app.canvas.press = None;+                if !inside || !rect.contains(pos) || middle_down {+                    continue;+                }+                // A press on the Dynamic Input options menu is the menu's (picked on release);+                // elsewhere it closes the menu and counts as usual.+                if app.cmd.options.is_some() {+                    if app.cmd.option_at(pos).is_some() {+                        continue;+                    }+                    app.cmd.options = None;+                }+                // Selection cycling: the press picks a row of the open list, or opens the list over+                // overlapping objects (#1149). Its release must not count again below.+                if cycle_click(app, &xf, Some(pos), Some(xf.to_world(pos)), m.shift) {+                    app.canvas.press = Some(press::Press::new(pos, false));+                    continue;+                }+                if press_takes(app, &xf, pos) {+                    let opened = press_at(app, &xf, ui.ctx(), pos, m);+                    app.canvas.press = Some(press::Press::new(pos, opened));+                }+            }+            press::Step::Move(pos) => {+                if let Some(p) = &mut app.canvas.press {+                    p.moved(pos, now);+                }+            }+            press::Step::Up(pos) => {+                let Some(mut p) = app.canvas.press.take() else { continue };+                click_used = true;+                // The release closes the selection window its press opened: always with PICKDRAG 1,+                // after a drag with 2; with 0 a window takes two clicks (#1032). Any selection+                // window closes so: the session's, or a command's own (TRIM's Crossing, a typed+                // W or C).+                let closes = match app.session.settings.pickdrag {+                    1 => true,+                    2 => p.released_as_drag(pos, now),+                    _ => false,                 };-                if let Err(e) = result {-                    app.session.echo(e.to_string());+                if closes && p.opened_window && app.session.selection_window().is_some() {+                    let end = xf.to_world(pos);+                    let done = if app.session.prompting() { app.session.input(Input::Point(end)) } else { app.session.idle_click(end, mods.shift) };+                    if let Err(e) = done {+                        app.session.echo(e.to_string());+                    }                 }             }         }     }+    if !primary_down {+        // The button came up where we didn't see it (outside the window, say).+        app.canvas.press = None;+    }      let raw_world = hover_pos.filter(|_| inside).map(|p| xf.to_world(p));     let dyn_frame = crate::cmdline::dyn_frame(app);-    if let Some(w) = raw_world {-        let mut eff = effective_point(app, w, &xf, ui.ctx());-        // A locked Dynamic Input value (`40,` or `40<` typed) holds the cursor and overrides snaps.-        if let Some(f) = dyn_frame-            && let Some(e) = crate::dyninput::parse(&app.cmd.buffer, &f)-            && e.first_locked().is_some()-        {-            eff = crate::dyninput::constrain(&e, &f, eff, app.cmd.second_locked());-            app.canvas.snap = None;-            app.canvas.track = None;-        }-        // Grip dimensional input: typed lengths and angles hold the grip and override snaps.-        let grip_held = crate::grip_dyn::constrain(app, eff);-        if let Some(q) = grip_held {-            eff = q;-            app.canvas.snap = None;-            app.canvas.track = None;-        }-        app.canvas.cursor = Some(eff);-        app.session.cursor = eff;-        app.session.cursor_deferred = app.canvas.snap.and_then(|h| h.deferred);-        // Typed or menu snap overrides (END, NON…) and Tab cycling; a locked Dynamic Input value wins.-        if dyn_frame.and_then(|f| crate::dyninput::parse(&app.cmd.buffer, &f)).is_none_or(|e| e.first_locked().is_none()) {-            override_cursor(app, w, &xf);-        }-        // Typed grip dimensions win over snap overrides too.-        if let Some(q) = grip_held {-            app.canvas.cursor = Some(q);-            app.session.cursor = q;-            app.canvas.snap = None;-            app.session.cursor_deferred = None;-        }-    } else {-        app.canvas.cursor = None;-        app.canvas.snap = None;-        app.session.cursor_deferred = None;-    }+    track_cursor(app, raw_world, &xf, ui.ctx(), dyn_frame);      // A click on the Dynamic Input options menu picks the keyword; elsewhere it closes the menu.     let mut picked = false;@@ -1646,26 +1658,14 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {     let idle_dbl = dbl_primary && app.session.running.is_none();     if idle_dbl {         app.session.pending_window = None;-        app.canvas.drag_window = false;+        app.canvas.press = None;     }     let pressed_primary = pressed_primary && !picked && !hatch_dialog && !idle_dbl;-    let key: CycleKey = (-        app.session.running.as_ref().map(|r| r.id.clone()),-        app.session.current_prompt().map(|p| p.message),-        app.session.state().map_or(0, |st| st.uid),-        app.session.state().map_or(0, |st| st.revision),-        xf.center,-        xf.scale,-    );-    if app.canvas.cycle_key.as_ref() != Some(&key) {-        app.canvas.cycle = None;-        app.canvas.hover_cycle = None;-        app.canvas.cycle_key = Some(key);-    }-    let cycled = pressed_primary && !middle_down && cycle_click(app, &xf, hover_pos, raw_world.filter(|_| inside), mods.shift);-    // Clicks.+    let cycled = pressed_primary && !click_used && !middle_down && cycle_click(app, &xf, hover_pos, raw_world.filter(|_| inside), mods.shift);+    // Clicks the presses above left alone: on grips, and during ZOOM Realtime.     if inside         && pressed_primary+        && !click_used         && !middle_down         && !cycled         && let Some(p) = app.canvas.cursor@@ -1697,36 +1697,6 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {             app.session.echo(e.to_string());         }     }-    // Press-and-drag selection window (PICKDRAG = 2; click-click still works): a drag-    // that starts while objects are being selected opens a window where the button went down,-    // and letting go closes it. Drags on grips and drags while drawing are left alone.-    if !app.session.settings.pickdrag {-        app.canvas.drag_window = false;-    }-    if app.session.settings.pickdrag-        && resp.drag_started_by(egui::PointerButton::Primary)-        && !middle_down-        && app.canvas.cycle.is_none()-        && app.session.pending_window.is_none()-        && let Some(origin) = ui.input(|i| i.pointer.press_origin())-        && rect.contains(origin)-        && grip_at(app, &xf, Some(origin)).is_none()-        && crate::dyngrips::at(app, &xf, Some(origin)).is_none()-    {-        app.canvas.drag_window = app.session.begin_window(xf.to_world(origin));-    }-    if app.canvas.drag_window && resp.drag_stopped_by(egui::PointerButton::Primary) {-        app.canvas.drag_window = false;-        if app.session.pending_window.is_some()-            && let Some(end) = ui.input(|i| i.pointer.interact_pos())-        {-            let end = xf.to_world(end);-            let done = if app.session.prompting() { app.session.input(Input::Point(end)) } else { app.session.idle_click(end, mods.shift) };-            if let Err(e) = done {-                app.session.echo(e.to_string());-            }-        }-    }     // Right-click acts as Enter while a command runs (the classic CAD default; held, it opens the     // command menu); otherwise it opens the shortcut menu (Repeat, Clipboard, …).     // Shift/Ctrl+right-click opens the object snap menu.@@ -1752,7 +1722,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {     // The cursor picks objects (pick box, rollover highlight): "Select objects" and single-object     // prompts, or no command with PICKFIRST on; SELECTIONPREVIEW says in which of those it shows.     let selecting = app.session.previews_selection();-    if inside && selecting && app.session.pending_window.is_none() {+    if inside && selecting && app.session.selection_window().is_none() {         if hover_pos != app.canvas.hover_at             && let (Some(w), Ok(st)) = (raw_world, app.session.state())         {@@ -1994,12 +1964,12 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {             draw_list(&painter, &xf, &list, bg, false);         }     }-    // Pending selection window.-    if let (Some(pw), Some(c)) = (app.session.pending_window, raw_world) {-        let a = xf.to_screen(pw.corner);+    // The selection window waiting for its opposite corner, whoever opened it.+    if let (Some(w), Some(c)) = (app.session.selection_window(), raw_world) {+        let a = xf.to_screen(w.corner);         let b = xf.to_screen(c);         let r = Rect::from_two_pos(a, b);-        let crossing = c.x < pw.corner.x;+        let crossing = w.crossing_to(c);         if let Some(fill) = selection_area_fill(&app.session.settings, crossing) {             painter.rect_filled(r, 0.0, fill);         }@@ -2066,6 +2036,95 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {     // spin the CPU with the low-latency present mode (`gpu::surface_config`). } +/// Put the cursor at `raw` (world, `None` off the canvas): snaps, ortho and polar, a locked+/// Dynamic Input value and typed grip dimensions applied (`app.canvas.cursor`, `.snap`, `.track`).+fn track_cursor(app: &mut CadApp, raw: Option<Vec2>, xf: &Xf, ctx: &egui::Context, dyn_frame: Option<crate::dyninput::Frame>) {+    if let Some(w) = raw {+        let mut eff = effective_point(app, w, xf, ctx);+        // A locked Dynamic Input value (`40,` or `40<` typed) holds the cursor and overrides snaps.+        if let Some(f) = dyn_frame+            && let Some(e) = crate::dyninput::parse(&app.cmd.buffer, &f)+            && e.first_locked().is_some()+        {+            eff = crate::dyninput::constrain(&e, &f, eff, app.cmd.second_locked());+            app.canvas.snap = None;+            app.canvas.track = None;+        }+        // Grip dimensional input: typed lengths and angles hold the grip and override snaps.+        let grip_held = crate::grip_dyn::constrain(app, eff);+        if let Some(q) = grip_held {+            eff = q;+            app.canvas.snap = None;+            app.canvas.track = None;+        }+        app.canvas.cursor = Some(eff);+        app.session.cursor = eff;+        app.session.cursor_deferred = app.canvas.snap.and_then(|h| h.deferred);+        // Typed or menu snap overrides (END, NON…) and Tab cycling; a locked Dynamic Input value wins.+        if dyn_frame.and_then(|f| crate::dyninput::parse(&app.cmd.buffer, &f)).is_none_or(|e| e.first_locked().is_none()) {+            override_cursor(app, w, xf);+        }+        // Typed grip dimensions win over snap overrides too.+        if let Some(q) = grip_held {+            app.canvas.cursor = Some(q);+            app.session.cursor = q;+            app.canvas.snap = None;+            app.session.cursor_deferred = None;+        }+    } else {+        app.canvas.cursor = None;+        app.canvas.snap = None;+        app.session.cursor_deferred = None;+    }+}++/// Whether the press at `pos` is acted on when the button goes down ([`press_at`]): at any prompt+/// and on the drawing with no command running. Grips (with no command running) and ZOOM Realtime+/// keep their clicks.+fn press_takes(app: &mut CadApp, xf: &Xf, pos: Pos2) -> bool {+    if app.session.realtime_zoom_active() {+        return false;+    }+    if app.session.prompting() {+        return true;+    }+    crate::dyngrips::at(app, xf, Some(pos)).is_none() && grip_at(app, xf, Some(pos)).is_none()+}++/// Act on a press at `pos`: at a prompt the point goes in (snapped as the cursor would be there);+/// with no command running it picks the object there or sets a selection window's corner.+/// Returns whether the press opened a selection window ([`cadcraft_engine::Session::selection_window`]).+fn press_at(app: &mut CadApp, xf: &Xf, ctx: &egui::Context, pos: Pos2, mods: egui::Modifiers) -> bool {+    let had_window = app.session.selection_window().is_some();+    let raw = xf.to_world(pos);+    let dyn_frame = crate::cmdline::dyn_frame(app);+    track_cursor(app, Some(raw), xf, ctx, dyn_frame);+    let Some(p) = app.canvas.cursor else { return false };+    if app.session.prompting() {+        // Ctrl held while picking the point of a grip edit makes copies (the Copy option).+        if mods.ctrl && app.session.running.as_ref().is_some_and(|r| r.id == "grip") {+            let _ = app.session.input(Input::Keyword("Copy".into()));+        }+        // Clicking places the point; a value typed into the Dynamic Input boxes is used up.+        if let Some(f) = dyn_frame+            && crate::dyninput::parse(&app.cmd.buffer, &f).is_some()+        {+            app.cmd.buffer.clear();+        }+        // Preserve a snap's viewport origin for DIST, and its deferred curve where needed.+        let result = match app.canvas.snap {+            Some(hit) => app.session.input_snapped(hit),+            None => app.session.input(Input::Point(p)),+        };+        if let Err(e) = result {+            app.session.echo(e.to_string());+        }+    } else if let Err(e) = app.session.idle_click(raw, mods.shift) {+        app.session.echo(e.to_string());+    }+    !had_window && app.session.selection_window().is_some()+}+ /// The model space tiles on screen (none without tiling): each tile's rectangle in `full`. /// Start TEXTEDIT on the text, mtext or attribute definition at `p` (the objects its shortcut menu /// offers Edit Text for); false when there is none.@@ -2954,6 +3013,7 @@ mod blockedit; mod compare;  /// Background rebuilds of the display list for a new view.+mod press; mod rebuild;  #[cfg(test)]diff --git a/crates/ui-egui/src/canvas/press.rs b/crates/ui-egui/src/canvas/press.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/canvas/press.rs@@ -0,0 +1,131 @@+//! Press-first clicks (#1001): a click on the canvas counts when the primary button goes down, at+//! the spot where it went down, however fast the mouse is moving. Where the pointer goes while the+//! button is held only decides whether the press also becomes a drag:+//!+//! - holding still, or staying within [`TOLERANCE`] of the press, never makes a drag;+//! - once the pointer leaves the tolerance a timer starts; a button still down [`DRAG_DELAY`]+//!   later is a drag (a selection window the press opened closes where the button comes up);+//! - a button that comes up sooner was a click (the window waits for the next press).+//!+//! The timer starts on leaving the tolerance, not on the press: pressing, holding still and then+//! moving and letting go quickly is still a click, as in AutoCAD.++use egui::{Modifiers, Pos2};++/// How far (points) the pointer may wander from the press before the timer starts.+pub(crate) const TOLERANCE: f32 = 6.0;+/// How long (seconds) the button must stay down after the pointer left [`TOLERANCE`] for the+/// press to be a drag.+pub(crate) const DRAG_DELAY: f64 = 0.25;++/// A primary-button press being held.+#[derive(Clone, Copy, Debug)]+pub(crate) struct Press {+    origin: Pos2,+    /// When the pointer first went further than [`TOLERANCE`] from `origin` (seconds).+    left_at: Option<f64>,+    /// The press opened a selection window (set its first corner).+    pub(crate) opened_window: bool,+}++impl Press {+    pub(crate) fn new(origin: Pos2, opened_window: bool) -> Self {+        Self { origin, left_at: None, opened_window }+    }++    /// The pointer is at `pos` at time `now`.+    pub(crate) fn moved(&mut self, pos: Pos2, now: f64) {+        if self.left_at.is_none() && pos.distance(self.origin) > TOLERANCE {+            self.left_at = Some(now);+        }+    }++    /// Whether the button coming up at `pos` at time `now` ends a drag rather than a click.+    pub(crate) fn released_as_drag(&mut self, pos: Pos2, now: f64) -> bool {+        self.moved(pos, now);+        self.left_at.is_some_and(|t| now - t >= DRAG_DELAY)+    }+}++/// A primary-button event of the frame, in order.+#[derive(Clone, Copy, Debug, PartialEq)]+pub(crate) enum Step {+    Down(Pos2, Modifiers),+    Move(Pos2),+    Up(Pos2),+}++/// The primary-button presses, pointer moves and releases among a frame's events, in order. A slow+/// frame can hold several clicks; each press is its own step (#815).+pub(crate) fn steps(events: &[egui::Event]) -> Vec<Step> {+    events+        .iter()+        .filter_map(|e| match *e {+            egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers } => Some(Step::Down(pos, modifiers)),+            egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, .. } => Some(Step::Up(pos)),+            egui::Event::PointerMoved(pos) => Some(Step::Move(pos)),+            _ => None,+        })+        .collect()+}++#[cfg(test)]+mod tests {+    use super::*;+    use egui::pos2;++    #[test]+    fn staying_within_the_tolerance_is_a_click_however_long_the_button_is_held() {+        let mut p = Press::new(pos2(100.0, 100.0), true);+        p.moved(pos2(104.0, 103.0), 0.5);+        assert!(!p.released_as_drag(pos2(105.0, 100.0), 5.0));+    }++    #[test]+    fn letting_go_soon_after_leaving_the_tolerance_is_a_click() {+        let mut p = Press::new(pos2(100.0, 100.0), true);+        p.moved(pos2(112.0, 100.0), 1.0);+        assert!(!p.released_as_drag(pos2(130.0, 100.0), 1.0 + DRAG_DELAY / 2.0));+    }++    #[test]+    fn holding_still_then_moving_and_letting_go_quickly_is_a_click() {+        // The timer starts when the pointer leaves the tolerance, not when the button goes down.+        let mut p = Press::new(pos2(100.0, 100.0), true);+        p.moved(pos2(101.0, 100.0), 2.0);+        assert!(!p.released_as_drag(pos2(140.0, 100.0), 2.05));+    }++    #[test]+    fn a_button_still_down_after_the_delay_is_a_drag() {+        let mut p = Press::new(pos2(100.0, 100.0), true);+        p.moved(pos2(110.0, 100.0), 1.0);+        p.moved(pos2(200.0, 150.0), 1.1);+        assert!(p.released_as_drag(pos2(250.0, 160.0), 1.0 + DRAG_DELAY));+    }++    #[test]+    fn steps_keep_every_press_of_a_slow_frame_in_order() {+        let at = |x| pos2(x, 10.0);+        let button =+            |x, pressed| egui::Event::PointerButton { pos: at(x), button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE };+        let events = vec![+            egui::Event::PointerMoved(at(1.0)),+            button(1.0, true),+            button(1.0, false),+            egui::Event::PointerButton { pos: at(2.0), button: egui::PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE },+            button(3.0, true),+            button(3.0, false),+        ];+        assert_eq!(+            steps(&events),+            vec![+                Step::Move(at(1.0)),+                Step::Down(at(1.0), Modifiers::NONE),+                Step::Up(at(1.0)),+                Step::Down(at(3.0), Modifiers::NONE),+                Step::Up(at(3.0))+            ]+        );+    }+}diff --git a/crates/ui-egui/src/options/dialog.rs b/crates/ui-egui/src/options/dialog.rs--- a/crates/ui-egui/src/options/dialog.rs+++ b/crates/ui-egui/src/options/dialog.rs@@ -370,7 +370,7 @@ fn selection(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>)             changes.insert("PICKADD".into(), json!(i32::from(!shift_adds)));         }         let mut grouping = st.pickstyle & 1 != 0;-        let mut two_clicks = !st.pickdrag;+        let mut two_clicks = st.pickdrag == 0;         if left             .checkbox(&mut two_clicks, crate::tl!("Select using two clicks only"))             .on_hover_text(crate::tl!("Define each corner of the selection window with a separate click."))diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs--- a/crates/ui-egui/tests/it/main.rs+++ b/crates/ui-egui/tests/it/main.rs@@ -22,9 +22,11 @@ mod native_windows; mod options_dialog; mod parameters_rename; mod pickdrag;+mod press_first_clicks; mod properties_units; mod rapid_clicks; mod selection_cycling;+mod selection_windows; mod shared_controls; mod shortcut_menus; mod status_bar_toggles;diff --git a/crates/ui-egui/tests/it/pickdrag.rs b/crates/ui-egui/tests/it/pickdrag.rs--- a/crates/ui-egui/tests/it/pickdrag.rs+++ b/crates/ui-egui/tests/it/pickdrag.rs@@ -45,11 +45,11 @@ fn selection_tab_checkbox_updates_and_persists_the_preference() {     h.run_steps(3);     h.get_by_label("Select using two clicks only").click();     h.run_steps(2);-    assert!(!h.state().session.settings.pickdrag);+    assert_eq!(h.state().session.settings.pickdrag, 0);     let saved = h.state().profile_json();     let mut restored = CadApp::new(Session::new(), Services::default());     restored.load_profile(&saved);-    assert!(!restored.session.settings.pickdrag);+    assert_eq!(restored.session.settings.pickdrag, 0);     if let Some(dir) = std::env::var_os("CADCRAFT_SELECTION_SHOTS") {         let dir = std::path::PathBuf::from(dir);         std::fs::create_dir_all(&dir).unwrap();@@ -57,7 +57,7 @@ fn selection_tab_checkbox_updates_and_persists_the_preference() {     }     h.get_by_label("Select using two clicks only").click();     h.run_steps(2);-    assert!(h.state().session.settings.pickdrag);+    assert_eq!(h.state().session.settings.pickdrag, 2); }  #[test]@@ -83,13 +83,11 @@ fn drag_is_disabled_but_two_clicks_still_complete_the_window() {         button(&mut h, a, true);         button(&mut h, a, false);         assert!(h.state().session.pending_window.is_some());-        // A held-button movement after the first click must not consume the second corner.-        drag(&mut h, a, b);-        assert!(h.state().session.pending_window.is_some());-        assert!(h.state().session.selection().is_empty());-        h.run_steps(30); // Separate deliberate clicks from egui's double-click interval.-        button(&mut h, b, true);-        button(&mut h, b, false);+        // The second corner is where the button goes down (#1001): a drag on from there, let go+        // elsewhere, changes nothing.+        h.run_steps(30); // Separate deliberate clicks from the double-click interval.+        let away = xf.to_screen(Vec2::new(30.0, 8.0));+        drag(&mut h, b, away);         assert!(h.state().session.pending_window.is_none());         assert_eq!(h.state().session.selection().len(), 1);     }diff --git a/crates/ui-egui/tests/it/press_first_clicks.rs b/crates/ui-egui/tests/it/press_first_clicks.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/it/press_first_clicks.rs@@ -0,0 +1,189 @@+//! Press-first clicks (#1001): a click counts when the button goes down, where it went down, even+//! while the mouse is moving; holding the button after the pointer left a small tolerance makes a+//! drag. The harness runs at 60 frames a second and each queued event is one frame, so the number+//! of moves between press and release is how long the button was held.++use cadcraft_engine::Session;+use cadcraft_engine::doc::{Drawing, EntityKind, Space};+use cadcraft_engine::geom::Vec2;+use cadcraft_engine::prompt::Input;+use cadcraft_ui_egui::{CadApp, Services};+use egui::{Event, Modifiers, PointerButton, Pos2, vec2};+use egui_kittest::Harness;+use serde_json::json;++fn harness() -> Harness<'static, CadApp> {+    let mut session = Session::empty();+    session.open_drawing(Drawing::new_imperial(), "Press first", None);+    session.execute("view.set", &json!({"center":[50,50],"height":100})).unwrap();+    session.settings.osmode = 0;+    session.settings.snapmode = false;+    session.settings.orthomode = false;+    session.execute("line", &json!({"points": [[20, 20], [60, 20]]})).unwrap();+    let mut app = CadApp::new(session, Services::default());+    app.canvas.zoom_pending = false;+    let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_ui_state(+        |ui, app: &mut CadApp| {+            app.logic(ui.ctx());+            app.ui(ui);+        },+        app,+    );+    h.run_steps(5);+    h+}++fn screen(h: &Harness<'_, CadApp>, p: Vec2) -> Pos2 {+    h.state().canvas.xf.unwrap().to_screen(p)+}++fn button(h: &mut Harness<'_, CadApp>, pos: Pos2, pressed: bool) {+    h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });+}++/// Press at `at`, then move `dx` points to the right over `frames` frames and let go there.+fn moving_click(h: &mut Harness<'_, CadApp>, at: Pos2, dx: f32, frames: usize) {+    h.event(Event::PointerMoved(at));+    button(h, at, true);+    for i in 1..=frames {+        h.event(Event::PointerMoved(at + vec2(dx * i as f32 / frames as f32, 0.0)));+    }+    button(h, at + vec2(dx, 0.0), false);+    h.run_steps(2);+}++/// A click that moves 15 points while the button is down, let go within two frames.+fn quick_moving_click(h: &mut Harness<'_, CadApp>, at: Vec2) {+    let pos = screen(h, at);+    moving_click(h, pos, 15.0, 2);+}++fn start(h: &mut Harness<'_, CadApp>, id: &str) {+    h.state_mut().start(id);+    h.run_steps(2);+    assert_eq!(h.state().session.running.as_ref().map(|r| r.id.as_str()), Some(id));+}++fn enter(h: &mut Harness<'_, CadApp>) {+    h.state_mut().session.input(Input::Enter).unwrap();+    h.run_steps(2);+}++fn lines(h: &Harness<'_, CadApp>) -> Vec<(Vec2, Vec2)> {+    let st = h.state().session.state().unwrap();+    st.doc+        .space(&Space::Model)+        .unwrap()+        .iter()+        .filter_map(|e| match &e.kind {+            EntityKind::Line(l) => Some((l.a.xy(), l.b.xy())),+            _ => None,+        })+        .collect()+}++#[test]+fn move_takes_the_base_and_second_point_where_the_button_went_down() {+    let mut h = harness();+    start(&mut h, "move");+    quick_moving_click(&mut h, Vec2::new(40.0, 20.0));+    enter(&mut h);+    // Base point and second point, both clicked on the move: 15 points is 1.5 drawing units here.+    quick_moving_click(&mut h, Vec2::new(30.0, 40.0));+    quick_moving_click(&mut h, Vec2::new(50.0, 40.0));+    let got = lines(&h);+    assert_eq!(got.len(), 1, "{got:?}");+    assert!(got[0].0.near(Vec2::new(40.0, 20.0), 1e-3) && got[0].1.near(Vec2::new(80.0, 20.0), 1e-3), "moved by (20, 0): {got:?}");+}++#[test]+fn an_object_clicked_on_the_move_is_picked_with_no_command_running() {+    let mut h = harness();+    quick_moving_click(&mut h, Vec2::new(40.0, 20.0));+    assert_eq!(h.state().session.selection().len(), 1);+    assert!(h.state().session.pending_window.is_none());+}++#[test]+fn an_object_clicked_on_the_move_is_picked_at_select_objects() {+    let mut h = harness();+    start(&mut h, "erase");+    quick_moving_click(&mut h, Vec2::new(40.0, 20.0));+    enter(&mut h);+    assert!(lines(&h).is_empty(), "the line was picked and erased");+}++#[test]+fn both_corners_of_a_click_click_window_count_while_the_mouse_moves() {+    let mut h = harness();+    // Lower left, then upper right: a window around the whole line.+    quick_moving_click(&mut h, Vec2::new(10.0, 10.0));+    assert!(h.state().session.pending_window.is_some(), "a quick click leaves the window open for the second corner");+    quick_moving_click(&mut h, Vec2::new(70.0, 30.0));+    assert!(h.state().session.pending_window.is_none());+    assert_eq!(h.state().session.selection().len(), 1);+}++#[test]+fn holding_the_button_while_moving_drags_a_window() {+    let mut h = harness();+    // Half a second of dragging from lower left to upper right of the line.+    let from = screen(&h, Vec2::new(10.0, 10.0));+    let to = screen(&h, Vec2::new(70.0, 30.0));+    h.event(Event::PointerMoved(from));+    button(&mut h, from, true);+    for i in 1..=30 {+        h.event(Event::PointerMoved(from + (to - from) * (i as f32 / 30.0)));+    }+    button(&mut h, to, false);+    h.run_steps(2);+    assert!(h.state().session.pending_window.is_none(), "letting go closes the dragged window");+    assert_eq!(h.state().session.selection().len(), 1);+}++#[test]+fn holding_still_then_moving_and_letting_go_quickly_is_a_click() {+    let mut h = harness();+    let at = screen(&h, Vec2::new(10.0, 10.0));+    h.event(Event::PointerMoved(at));+    button(&mut h, at, true);+    // Held in place for half a second: no drag yet.+    h.run_steps(30);+    h.event(Event::PointerMoved(at + vec2(40.0, 0.0)));+    button(&mut h, at + vec2(40.0, 0.0), false);+    h.run_steps(2);+    assert!(h.state().session.pending_window.is_some(), "still waiting for the second corner");+}++#[test]+fn two_window_corners_clicked_within_one_frame_both_count() {+    let mut h = harness();+    let (a, b) = (screen(&h, Vec2::new(10.0, 10.0)), screen(&h, Vec2::new(70.0, 30.0)));+    // A slow frame: both clicks arrive before the next one is drawn.+    for pos in [a, b] {+        h.input_mut().events.push(Event::PointerMoved(pos));+        for pressed in [true, false] {+            h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });+        }+    }+    h.run_steps(2);+    assert!(h.state().session.pending_window.is_none());+    assert_eq!(h.state().session.selection().len(), 1);+}++#[test]+fn cpolygon_points_clicked_on_the_move_all_count() {+    // #1546: a narrow crossing polygon over the line's right end (x 58.6–59.9, the line ends at+    // 60), each point clicked while moving. Taken where the buttons came up (1.5 units further+    // right) it would miss the line.+    let mut h = harness();+    start(&mut h, "erase");+    h.state_mut().session.input(Input::Keyword("CP".into())).unwrap();+    h.run_steps(2);+    for p in [Vec2::new(58.6, 15.0), Vec2::new(59.9, 15.0), Vec2::new(59.9, 25.0), Vec2::new(58.6, 25.0)] {+        quick_moving_click(&mut h, p);+    }+    enter(&mut h);+    enter(&mut h);+    assert!(lines(&h).is_empty(), "the polygon crossed the line and ERASE removed it");+}diff --git a/crates/ui-egui/tests/it/rapid_clicks.rs b/crates/ui-egui/tests/it/rapid_clicks.rs--- a/crates/ui-egui/tests/it/rapid_clicks.rs+++ b/crates/ui-egui/tests/it/rapid_clicks.rs@@ -88,11 +88,12 @@ fn a_click_made_while_the_mouse_moves_still_places_the_point() {     let down = screen(&h, Vec2::new(40.0, 10.0));     button(&mut h, down, true);     h.run_steps(1);-    // The hand is already on its way to the next point when the button comes up.+    // The hand is already on its way to the next point when the button comes up; the point is+    // where the button went down (#1001), not where the hand got to.     let up = down + vec2(12.0, 0.0);     button(&mut h, up, false);     h.run_steps(2);     let got = lines(&h);     assert_eq!(got.len(), 1, "{got:?}");-    assert!(got[0].1.near(h.state().canvas.xf.unwrap().to_world(up), 1e-4), "{got:?}");+    assert!(got[0].1.near(h.state().canvas.xf.unwrap().to_world(down), 1e-4), "{got:?}"); }diff --git a/crates/ui-egui/tests/it/selection_windows.rs b/crates/ui-egui/tests/it/selection_windows.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/it/selection_windows.rs@@ -0,0 +1,171 @@+//! STRETCH and TRIM select like every other command (#1001 follow-up, #1116): a press counts at+//! once, a click-click window waits for the next press, and a drag closes the window its press+//! opened where the button comes up (PICKDRAG 2), whether the window is the session's or TRIM's+//! own crossing. The harness runs at 60 frames a second; each queued event is one frame.++use cadcraft_engine::Session;+use cadcraft_engine::doc::{Drawing, EntityKind, Space};+use cadcraft_engine::geom::Vec2;+use cadcraft_engine::prompt::Input;+use cadcraft_ui_egui::{CadApp, Services};+use egui::{Event, Modifiers, PointerButton, Pos2, vec2};+use egui_kittest::Harness;+use serde_json::json;++/// A horizontal line from (20, 20) to (60, 20) and a vertical one at x = 40 crossing it.+fn session() -> Session {+    let mut s = Session::empty();+    s.open_drawing(Drawing::new_imperial(), "Selection windows", None);+    s.execute("view.set", &json!({"center":[50,50],"height":100})).unwrap();+    s.settings.osmode = 0;+    s.settings.snapmode = false;+    s.settings.orthomode = false;+    s.execute("line", &json!({"points": [[20, 20], [60, 20]]})).unwrap();+    s.execute("line", &json!({"points": [[40, 10], [40, 30]]})).unwrap();+    s+}++fn harness() -> Harness<'static, CadApp> {+    let mut app = CadApp::new(session(), Services::default());+    app.canvas.zoom_pending = false;+    let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_ui_state(+        |ui, app: &mut CadApp| {+            app.logic(ui.ctx());+            app.ui(ui);+        },+        app,+    );+    h.run_steps(5);+    h+}++fn screen(h: &Harness<'_, CadApp>, p: Vec2) -> Pos2 {+    h.state().canvas.xf.unwrap().to_screen(p)+}++fn button(h: &mut Harness<'_, CadApp>, pos: Pos2, pressed: bool) {+    h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });+}++/// Press at `a`, move to `b` over `frames` frames and let go there (30 frames: half a second, a+/// drag; 2 frames: a click made on the move).+fn sweep(h: &mut Harness<'_, CadApp>, a: Vec2, b: Vec2, frames: usize) {+    let (from, to) = (screen(h, a), screen(h, b));+    h.event(Event::PointerMoved(from));+    button(h, from, true);+    for i in 1..=frames {+        h.event(Event::PointerMoved(from + (to - from) * (i as f32 / frames as f32)));+    }+    button(h, to, false);+    h.run_steps(2);+}++fn start(h: &mut Harness<'_, CadApp>, id: &str) {+    h.state_mut().start(id);+    h.run_steps(2);+    assert_eq!(h.state().session.running.as_ref().map(|r| r.id.as_str()), Some(id));+}++fn input(h: &mut Harness<'_, CadApp>, i: Input) {+    h.state_mut().session.input(i).unwrap();+    h.run_steps(2);+}++fn lines(s: &Session) -> Vec<(Vec2, Vec2)> {+    let mut v: Vec<(Vec2, Vec2)> = s+        .state()+        .unwrap()+        .doc+        .space(&Space::Model)+        .unwrap()+        .iter()+        .filter_map(|e| match &e.kind {+            EntityKind::Line(l) => Some((l.a.xy(), l.b.xy())),+            _ => None,+        })+        .collect();+    v.sort_by(|p, q| (p.0.x, p.0.y, p.1.x, p.1.y).partial_cmp(&(q.0.x, q.0.y, q.1.x, q.1.y)).unwrap());+    v+}++/// STRETCH the right end of the horizontal line 5 to the right after selecting with `select`.+fn stretch_with(select: impl Fn(&mut Harness<'_, CadApp>)) -> Vec<(Vec2, Vec2)> {+    let mut h = harness();+    start(&mut h, "stretch");+    select(&mut h);+    assert!(h.state().session.selection_window().is_none(), "the window closed");+    input(&mut h, Input::Enter);+    input(&mut h, Input::Point(Vec2::new(0.0, 0.0)));+    input(&mut h, Input::Point(Vec2::new(5.0, 0.0)));+    assert!(h.state().session.running.is_none(), "{}", h.state().session.prompt_text());+    lines(&h.state().session)+}++#[test]+fn a_stretch_window_closes_by_drag_or_by_two_clicks_on_the_move() {+    let expected = vec![(Vec2::new(20.0, 20.0), Vec2::new(65.0, 20.0)), (Vec2::new(40.0, 10.0), Vec2::new(40.0, 30.0))];+    // Right to left over the horizontal line's right end only: a crossing.+    let dragged = stretch_with(|h| sweep(h, Vec2::new(70.0, 25.0), Vec2::new(55.0, 15.0), 30));+    assert_eq!(dragged, expected, "a drag closes STRETCH's crossing where the button comes up");+    let clicked = stretch_with(|h| {+        sweep(h, Vec2::new(70.0, 25.0), Vec2::new(70.5, 25.0), 2);+        assert!(h.state().session.selection_window().is_some(), "a click leaves the window open");+        sweep(h, Vec2::new(55.0, 15.0), Vec2::new(57.0, 15.0), 2);+    });+    assert_eq!(clicked, expected, "the second corner is where the button went down");+}++/// TRIM's crossing on the right of the cutting line, made in a session by typed points.+fn trimmed_by_typed_crossing() -> Vec<(Vec2, Vec2)> {+    let mut s = session();+    s.viewport_px = (1600.0, 1000.0);+    s.start("trim").unwrap();+    s.input(Input::Point(Vec2::new(55.0, 25.0))).unwrap();+    s.input(Input::Point(Vec2::new(45.0, 15.0))).unwrap();+    s.input(Input::Enter).unwrap();+    lines(&s)+}++#[test]+fn trims_crossing_closes_where_a_drag_lets_go() {+    let mut h = harness();+    start(&mut h, "trim");+    sweep(&mut h, Vec2::new(55.0, 25.0), Vec2::new(45.0, 15.0), 30);+    assert!(h.state().session.selection_window().is_none(), "the drag closed TRIM's crossing");+    input(&mut h, Input::Enter);+    let got = lines(&h.state().session);+    assert_eq!(got, trimmed_by_typed_crossing());+    assert!(got.iter().all(|(a, b)| a.x <= 40.0 + 1e-9 && b.x <= 40.0 + 1e-9), "the right part is trimmed off: {got:?}");+}++#[test]+fn with_pickdrag_off_a_drag_leaves_trims_crossing_open() {+    let mut h = harness();+    h.state_mut().run("options", json!({"PICKDRAG": 0})).unwrap();+    start(&mut h, "trim");+    sweep(&mut h, Vec2::new(55.0, 25.0), Vec2::new(45.0, 15.0), 30);+    let w = h.state().session.selection_window().expect("two clicks only: the window waits");+    assert!(w.corner.dist(Vec2::new(55.0, 25.0)) < 0.5, "{w:?}");+    sweep(&mut h, Vec2::new(45.0, 15.0), Vec2::new(45.0, 15.0), 1);+    assert!(h.state().session.selection_window().is_none());+    input(&mut h, Input::Enter);+    assert_eq!(lines(&h.state().session), trimmed_by_typed_crossing());+}++#[test]+fn with_pickdrag_1_the_release_always_closes_the_window() {+    // Drag only: even a quick press and release on the move closes the window where the button+    // comes up, at the session's "Select objects" and at TRIM's crossing alike.+    let mut h = harness();+    h.state_mut().run("options", json!({"PICKDRAG": 1})).unwrap();+    start(&mut h, "stretch");+    sweep(&mut h, Vec2::new(70.0, 25.0), Vec2::new(55.0, 15.0), 2);+    assert!(h.state().session.selection_window().is_none());+    assert_eq!(h.state().session.selection().len(), 1, "the crossing took the horizontal line");+    h.state_mut().session.cancel();+    start(&mut h, "trim");+    sweep(&mut h, Vec2::new(55.0, 25.0), Vec2::new(45.0, 15.0), 2);+    assert!(h.state().session.selection_window().is_none());+    input(&mut h, Input::Enter);+    assert_eq!(lines(&h.state().session), trimmed_by_typed_crossing());+}diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (DIMTFILL limits in the `*D` block, EXPLODE and DXF: F7 updated) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (PICKDRAG 1; STRETCH and TRIM share the selection windows: U6 narrowed) · **Target:** Autodesk AutoCAD 2027  Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR@@ -55,7 +55,8 @@ Outlined fill highlights now keep deterministic contour starts and directions ac repaints and GPU rebuilds, preventing the dashed preview from appearing to flicker (U6).  Options ▸ Selection can now require two separate clicks for a selection window (PICKDRAG 0)-or allow clicks and drag (2, default), saved in the user profile. Drag-only mode (1) remains unavailable.+or allow clicks and drag (2, default), or drag only (1), saved in the user profile. STRETCH and TRIM's+crossing use the same selection windows as every other command. | # | Gap | User impact | Est. | |---|---|---|---| | U4 | Tab snap cycling offers the closest snap of each mode, not every candidate. Typed snap overrides (`END`, `MID` … `NON`), the Shift/Ctrl+right-click snap menu and Tab cycling landed (#393), the menu's Temporary track point in #441 | Low | 1 |@@ -164,6 +165,7 @@ Detail: [localization-parity.md](localization-parity.md). | 2026-10-15 | minor | BREAKLINE (Tools ▸ Express Tools ▸ Break-line Symbol): one polyline between two points with a break symbol, Block/Size/Extension options, size times DIMSCALE: E3 narrowed | | 2026-10-15 | minor | TXT2MTXT (Tools ▸ Express Tools ▸ Convert Text to Mtext): selected TEXT, and MTEXT, combined into one MTEXT top-down or in selection order with optional word wrap, or one MTEXT per TEXT; SEttings remembered for the session: E3 narrowed | | 2026-10-13 | minor | Selection window gesture preference (PICKDRAG 0/2) added to Options ▸ Selection and saved in the profile (U6), with Portuguese and Ukrainian labels |+| 2026-10-23 | minor | PICKDRAG 1 (drag only); STRETCH selects through the session's "Select objects", TRIM's crossing and typed W/C corners are selection windows like the session's (U6 narrowed) | | 2026-10-12 | minor | Object transparency (entity, ByLayer, ByBlock) is drawn: on screen (TRANSPARENCYDISPLAY, on by default), in PNG/SVG exports, and in plots when the page setup's "Plot transparency" is on (PDF alpha, PNG blending, SVG opacity; PLOTTRANSPARENCYOVERRIDE): F14 narrowed | | 2026-10-12 | minor | Hatch and Gradient dialog (HATCH, GRADIENT; HPDLGMODE) and Hatch Edit dialog (HATCHEDIT, right-click, double-click a hatch): F13 narrowed to Preview, Inherit Properties and boundary add/remove in edit mode | | 2026-10-12 | minor | A bare system variable name typed at the command line (ORTHOMODE, GRIDMODE…) runs SETVAR on that variable, as in AutoCAD; U10 narrowed |diff --git a/docs/ui-parity.md b/docs/ui-parity.md--- a/docs/ui-parity.md+++ b/docs/ui-parity.md@@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (distinct polyline midpoint grips) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (STRETCH and TRIM select like every command; PICKDRAG 1) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)  How CADCraft's interaction compares with AutoCAD's: point entry, object snaps, tracking, dynamic input, grips, selection, the command line, chrome and navigation. A drafter's speed in AutoCAD@@ -127,7 +127,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. |---|---|---| | Pick, implied window (left→right) and crossing (right→left) | yes | yes, coloured and dashed rectangles (`lib.rs:576-589`, `canvas.rs:823-836`), filled as SELECTIONAREA, WINDOWAREACOLOR, CROSSINGAREACOLOR and SELECTIONAREAOPACITY say (Visual Effect Settings) | | 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 | clicks or drag (2, default), or two clicks only (0); Options ▸ Selection ▸ Select using two clicks only, saved in the profile; drag-only mode (1) is unavailable |+| Press-drag selection windows (PICKDRAG) | yes | clicks or drag (2, default), two clicks only (0) or drag only (1, the release closes the window); Options ▸ Selection ▸ Select using two clicks only, saved in the profile. Every selection window waiting for its corner, the session's or a command's own (TRIM's Crossing, a typed W or C), is drawn and closed alike. Clicks count when the button goes down, even while the mouse moves, and a press held after the pointer leaves a 6-point tolerance for 0.25 s is a drag; a press on an object picks it, and a drag from it does not open a window (#1001) | | Lasso | yes | **no** | | Selection cycling (SELECTIONCYCLING) | yes | yes (#1143): SELECTIONCYCLING 2 lists the objects under a click (type, layer; the row under the pointer highlights its object), 1 the badge; Shift+Space steps the highlight through them (#1145); no Ctrl+W | | Selection preview, hover highlight | yes | selected and hovered objects redrawn in the selection colour, fills half see-through (SELECTIONEFFECT 1; 0 dashed), selection effect color; HIGHLIGHT (not saved), SELECTIONPREVIEW, PREVIEWEFFECT, PREVIEWFILTER (Visual Effect Settings) |@@ -211,6 +211,8 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | 2026-10-16 | minor | ALIASEDIT opens the Command Aliases dialog (#1381) | | 2026-10-18 | minor | In-window menu bar: once a menu is open, pointing at another title closes it and opens that one (#1567) | | 2026-10-13 | minor | Options ▸ Selection adds Select using two clicks only; PICKDRAG 0 disables drag-window creation/completion, 2 preserves clicks or drag, and the preference survives restart; UI pointer and profile regressions |+| 2026-10-23 | minor | Clicks on the drawing count when the button goes down, at that spot, even while the mouse moves: points (base points too), object picks, both corners of a click-click window and polygon points; holding the button after the pointer leaves a 6-point tolerance for 0.25 s drags a window (PICKDRAG 2); a press on an object picks it, and a drag from it no longer opens a window; selection cycling opens and picks on the press too (#1001) |+| 2026-10-23 | minor | STRETCH selects at the session's "Select objects" like every other command (#1116): press-first picks, PICKDRAG drags, selection cycling, typed modes; the windows and polygons it selects with mark what stretches. TRIM's crossing and the typed W/C corners are selection windows like the session's: drawn with the selection area, closed by a drag, no object snaps at their corners. A typed fence or polygon shows its lines at any "Select objects". PICKDRAG 1 (drag only) is supported | | 2026-10-12 | minor | Selected and hovered objects redrawn in the selection colour (text and arrowheads included); Options ▸ Selection laid out as AutoCAD's: Object grouping, Show grips, grip object limit, selection preview, Visual Effect Settings (selection area, preview filter) | | 2026-10-12 | minor | Animation effects (dialog and menu fades, smoothed wheel scrolling and ⌘/Ctrl+wheel zoom) follow the operating system's setting; View ▸ Accessibility ▸ Animation Effects chooses System, On or Off | | 2026-10-12 | minor | Hatch and Gradient / Hatch Edit dialogs (HATCH, GRADIENT, HATCHEDIT, double-click a hatch; HPDLGMODE) |