mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-22 18:22:07 +00:00
Selection cycling: a click over overlapping objects lists them, Shift+Space steps through them (#1149)
* Selection cycling: a click over overlapping objects lists them, Shift+Space steps through them (fixes #1143, fixes #1145) * canvas: keep viewport_content's doc comment on it Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Brandon Thomas <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
8 files changed
+522
-14
No files matched your search
+81
-12
@@ -379,7 +379,11 @@ pub struct Settings {
|
||||
/// PLOTTRANSPARENCYOVERRIDE: 0 never plots transparency, 1 (default) uses the page setup's or
|
||||
/// the Plot dialog's "Plot transparency", 2 always plots it.
|
||||
pub plot_transparency_override: u8,
|
||||
/// Selection cycling on (the status-bar toggle, the sign of SELECTIONCYCLING).
|
||||
pub selection_cycling: bool,
|
||||
/// What selection cycling shows over overlapping objects (SELECTIONCYCLING's magnitude): 0
|
||||
/// nothing, 1 a badge, 2 (default) the badge and a list to pick from on a click.
|
||||
pub selection_cycling_display: u8,
|
||||
/// SELECTSIMILARMODE: what SELECTSIMILAR matches besides the object type (bits: 1 colour,
|
||||
/// 2 layer, 4 linetype, 8 linetype scale, 16 lineweight, 32 plot style, 64 object style,
|
||||
/// 128 name).
|
||||
@@ -476,6 +480,7 @@ impl Default for Settings {
|
||||
transparency_display: true,
|
||||
plot_transparency_override: 1,
|
||||
selection_cycling: false,
|
||||
selection_cycling_display: 2,
|
||||
selectsimilarmode: 130,
|
||||
otrack: true,
|
||||
pickbox: 3.0,
|
||||
@@ -1679,23 +1684,87 @@ impl Session {
|
||||
}
|
||||
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
|
||||
match select::pick(self.doc()?, &space, p, ap) {
|
||||
Some(h) => {
|
||||
// With group selection the object's selectable groups come (or go) with it.
|
||||
let hs = cmd::groups::expand(self, vec![h]);
|
||||
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
|
||||
let had = sel.contains(&h);
|
||||
if shift && (pickadd || had) {
|
||||
sel.retain(|x| !hs.contains(x));
|
||||
} else if !had {
|
||||
sel.extend(hs);
|
||||
}
|
||||
self.set_selection(sel);
|
||||
}
|
||||
Some(h) => self.idle_pick(h, shift),
|
||||
None => self.pending_window = Some(PendingWindow { corner: p, during_command: false }),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An idle click that picked `h`, with [`Self::idle_click`]'s PICKADD and Shift rules.
|
||||
fn idle_pick(&mut self, h: Handle, shift: bool) {
|
||||
let pickadd = self.settings.pickadd;
|
||||
// With group selection the object's selectable groups come (or go) with it.
|
||||
let hs = cmd::groups::expand(self, vec![h]);
|
||||
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
|
||||
let had = sel.contains(&h);
|
||||
if shift && (pickadd || had) {
|
||||
sel.retain(|x| !hs.contains(x));
|
||||
} else if !had {
|
||||
sel.extend(hs);
|
||||
}
|
||||
self.set_selection(sel);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn click_picks(&self) -> bool {
|
||||
self.pending_window.is_none()
|
||||
&& match self.running.as_ref() {
|
||||
None => self.lisp_prompt().is_none(),
|
||||
Some(r) => self.select_mode.is_none() && r.machine.prompt(self).accept.select,
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects under the pick box at `p` when selection cycling is on and more than one is
|
||||
/// there: what a click at `p` would choose between.
|
||||
fn cycling_hits(&self, p: Vec2) -> Vec<Handle> {
|
||||
if !self.settings.selection_cycling {
|
||||
return Vec::new();
|
||||
}
|
||||
let hs = self.pick_candidates(p);
|
||||
if hs.len() > 1 { hs } else { Vec::new() }
|
||||
}
|
||||
|
||||
/// Every object under the pick box at `p`, nearest first, when a click there picks objects
|
||||
/// (empty otherwise): what Shift+Space steps the highlight through.
|
||||
pub fn pick_candidates(&self, p: Vec2) -> Vec<Handle> {
|
||||
if !self.click_picks() {
|
||||
return Vec::new();
|
||||
}
|
||||
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
|
||||
self.doc().map(|d| select::pick_all(d, &self.space(), p, ap)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether selection cycling marks `p` with its badge (SELECTIONCYCLING 1 or 2): overlapping
|
||||
/// objects a click there would choose between.
|
||||
pub fn cycling_badge(&self, p: Vec2) -> bool {
|
||||
self.settings.selection_cycling_display >= 1 && !self.cycling_hits(p).is_empty()
|
||||
}
|
||||
|
||||
/// The objects a click at `p` lists to choose from (SELECTIONCYCLING 2), nearest first; empty
|
||||
/// when the click should pick as usual.
|
||||
pub fn cycling_candidates(&self, p: Vec2) -> Vec<Handle> {
|
||||
if self.settings.selection_cycling_display < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
self.cycling_hits(p)
|
||||
}
|
||||
|
||||
/// Pick `h` from the selection cycling list, as a click on it alone would have: into the
|
||||
/// command at "Select objects", else into the selection (Shift as for [`Self::idle_click`]).
|
||||
pub fn pick_cycled(&mut self, h: Handle, shift: bool) -> Result<()> {
|
||||
// The list may be older than an undo or an erase.
|
||||
if self.doc()?.entity(h).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.running.is_some() {
|
||||
let hs = cmd::groups::expand(self, vec![h]);
|
||||
return self.input(Input::Pick(hs));
|
||||
}
|
||||
self.idle_pick(h, shift);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remember the selection used by a command as "Previous".
|
||||
pub fn remember_selection(&mut self, sel: &[Handle]) {
|
||||
if let Ok(st) = self.state_mut() {
|
||||
|
||||
@@ -209,6 +209,21 @@ pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<S
|
||||
best.map(|(_, h)| h)
|
||||
}
|
||||
|
||||
/// Every entity within `aperture` of `p`, nearest first and topmost among equals (so the first is
|
||||
/// what [`pick`] chooses): the objects selection cycling lists.
|
||||
pub fn pick_all(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Vec<Handle> {
|
||||
let ix = spatial::index(d, space);
|
||||
let probe = Bounds2::new(p, p).expand(aperture);
|
||||
let Some(cands) = candidates(d, space, &ix, &probe.expand(aperture), true, true) else { return Vec::new() };
|
||||
let mut hits: Vec<(f64, Handle)> = cands
|
||||
.filter(|(e, known)| selectable(d, e) && (is_infinite(e) || bounds_of(d, e, *known).expand(aperture).intersects(&probe)))
|
||||
.map(|(e, _)| (entity_distance(d, e, p, aperture / 4.0), e.handle))
|
||||
.filter(|(dist, _)| *dist <= aperture)
|
||||
.collect();
|
||||
hits.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
hits.into_iter().map(|(_, h)| h).collect()
|
||||
}
|
||||
|
||||
/// Whether PREVIEWFILTER keeps `e` from being highlighted under the cursor: bits 1 objects on
|
||||
/// locked layers, 2 xrefs, 4 tables, 8 multiline text, 16 hatches, 32 objects in groups.
|
||||
pub fn preview_filtered(d: &Drawing, e: &Entity, filter: u8) -> bool {
|
||||
|
||||
@@ -36,6 +36,7 @@ const SESSION_VARS: &[&str] = &[
|
||||
"DYNPROMPT",
|
||||
"LWDISPLAY",
|
||||
"PLOTTRANSPARENCYOVERRIDE",
|
||||
"SELECTIONCYCLING",
|
||||
"SELECTSIMILARMODE",
|
||||
"OBJECTISOLATIONMODE",
|
||||
"QPMODE",
|
||||
@@ -104,6 +105,11 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
"DYNPROMPT" => json!(i32::from(st.dynprompt)),
|
||||
"LWDISPLAY" => json!(i32::from(st.lwdisplay)),
|
||||
"PLOTTRANSPARENCYOVERRIDE" => json!(st.plot_transparency_override),
|
||||
// Negative while turned off, so the toggle keeps the display it turns back on.
|
||||
"SELECTIONCYCLING" => {
|
||||
let d = i32::from(st.selection_cycling_display);
|
||||
json!(if st.selection_cycling { d } else { -d })
|
||||
}
|
||||
"SELECTSIMILARMODE" => json!(st.selectsimilarmode),
|
||||
// Isolation is temporary: hidden objects show again when the drawing is reopened.
|
||||
"OBJECTISOLATIONMODE" => json!(0),
|
||||
@@ -269,6 +275,11 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
"PLOTTRANSPARENCYOVERRIDE" => {
|
||||
st.plot_transparency_override = as_i(v).filter(|i| (0..=2).contains(i)).and_then(|i| u8::try_from(i).ok()).ok_or_else(bad)?
|
||||
}
|
||||
"SELECTIONCYCLING" => {
|
||||
let i = as_i(v).filter(|i| (-2..=2).contains(i)).ok_or_else(bad)?;
|
||||
st.selection_cycling = i > 0;
|
||||
st.selection_cycling_display = u8::try_from(i.unsigned_abs()).map_err(|_| bad())?;
|
||||
}
|
||||
"SELECTSIMILARMODE" => st.selectsimilarmode = as_i(v).filter(|b| (0..=255).contains(b)).ok_or_else(bad)? as u32,
|
||||
"OBJECTISOLATIONMODE" => match as_i(v) {
|
||||
Some(0) => {}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Selection cycling (SELECTIONCYCLING, #1143): the objects under the pick box a click chooses
|
||||
//! between, and picking one of them as the click would have.
|
||||
|
||||
use cadcraft_engine::doc::{Handle, Space};
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::{Session, select};
|
||||
use serde_json::json;
|
||||
|
||||
/// Two lines that overlap from x = 0 to x = 10 (the second drawn on top), and a line apart.
|
||||
fn drawing() -> (Session, Vec<Handle>) {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0, 0], [20, 0]] })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0, 50], [10, 50]] })).unwrap();
|
||||
let hs: Vec<Handle> = s.doc().unwrap().model.iter().map(|e| e.handle).collect();
|
||||
s.set_selection(Vec::new());
|
||||
(s, hs)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_all_lists_every_object_under_the_pick_box() {
|
||||
let (s, hs) = drawing();
|
||||
let d = s.doc().unwrap();
|
||||
// Topmost first, as a plain pick chooses it.
|
||||
assert_eq!(select::pick_all(d, &Space::Model, Vec2::new(5.0, 0.0), 0.5), vec![hs[1], hs[0]]);
|
||||
assert_eq!(select::pick(d, &Space::Model, Vec2::new(5.0, 0.0), 0.5), Some(hs[1]));
|
||||
// Where only one object is, only that one; the far line never.
|
||||
assert_eq!(select::pick_all(d, &Space::Model, Vec2::new(15.0, 0.0), 0.5), vec![hs[1]]);
|
||||
assert!(select::pick_all(d, &Space::Model, Vec2::new(5.0, 25.0), 0.5).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycling_candidates_follow_selectioncycling() {
|
||||
let (mut s, hs) = drawing();
|
||||
s.viewport_px = (1000.0, 1000.0);
|
||||
let at = Vec2::new(5.0, 0.0);
|
||||
// Off (AutoCAD's initial -2): a click picks as it always did.
|
||||
assert_eq!(s.execute("getvar", &json!({ "name": "SELECTIONCYCLING" })).unwrap()["value"], -2);
|
||||
assert!(s.cycling_candidates(at).is_empty());
|
||||
// 2: the list of both lines; a single object under the cursor needs no list.
|
||||
s.execute("setvar", &json!({ "name": "SELECTIONCYCLING", "value": 2 })).unwrap();
|
||||
assert_eq!(s.cycling_candidates(at), vec![hs[1], hs[0]]);
|
||||
assert!(s.cycling_candidates(Vec2::new(15.0, 0.0)).is_empty());
|
||||
// 1: the badge only, no list; the toggle keeps the display mode it turns off.
|
||||
s.execute("setvar", &json!({ "name": "SELECTIONCYCLING", "value": 1 })).unwrap();
|
||||
assert!(s.cycling_candidates(at).is_empty());
|
||||
assert!(s.cycling_badge(at));
|
||||
s.execute("selectioncycling", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("getvar", &json!({ "name": "SELECTIONCYCLING" })).unwrap()["value"], -1);
|
||||
assert!(!s.cycling_badge(at));
|
||||
s.execute("setvar", &json!({ "name": "SELECTIONCYCLING", "value": 0 })).unwrap();
|
||||
assert!(s.cycling_candidates(at).is_empty() && !s.cycling_badge(at));
|
||||
for v in [json!(3), json!(-3), json!("x")] {
|
||||
assert!(s.execute("setvar", &json!({ "name": "SELECTIONCYCLING", "value": v })).is_err(), "{v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cycled_pick_selects_like_a_click() {
|
||||
let (mut s, hs) = drawing();
|
||||
s.pick_cycled(hs[0], false).unwrap();
|
||||
assert_eq!(s.selection(), vec![hs[0]]);
|
||||
// Shift removes it again (PICKADD on).
|
||||
s.pick_cycled(hs[0], true).unwrap();
|
||||
assert!(s.selection().is_empty());
|
||||
// At "Select objects" the chosen object goes to the command.
|
||||
s.start("erase").unwrap();
|
||||
s.pick_cycled(hs[0], false).unwrap();
|
||||
s.input(cadcraft_engine::Input::Enter).unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let left: Vec<Handle> = s.doc().unwrap().model.iter().map(|e| e.handle).collect();
|
||||
assert_eq!(left, vec![hs[1], hs[2]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cycled_pick_of_an_erased_object_selects_nothing() {
|
||||
let (mut s, hs) = drawing();
|
||||
s.execute("erase", &json!({ "handles": [hs[0].hex()] })).unwrap();
|
||||
s.pick_cycled(hs[0], false).unwrap();
|
||||
assert!(s.selection().is_empty(), "a handle no longer in the drawing is never selected");
|
||||
}
|
||||
@@ -127,8 +127,22 @@ pub struct CanvasState {
|
||||
/// Tab was pressed over a snap marker: show the next candidate ([`SnapCycle`]).
|
||||
pub snap_tab: bool,
|
||||
pub snap_cycle: Option<SnapCycle>,
|
||||
/// The selection cycling list a click over overlapping objects opened (SELECTIONCYCLING 2).
|
||||
cycle: Option<CycleList>,
|
||||
/// Selection cycling's badge shows beside the cursor, and where it was last worked out.
|
||||
cycle_badge: bool,
|
||||
cycle_badge_at: Option<Pos2>,
|
||||
/// Shift+Space stepping the highlight through the objects under the cursor (selection
|
||||
/// cycling's temporary override): where it started, the objects, which one is highlighted.
|
||||
hover_cycle: Option<(Pos2, Vec<Handle>, usize)>,
|
||||
/// What the cycling list and the Shift+Space highlight were made for (see [`CycleKey`]).
|
||||
cycle_key: Option<CycleKey>,
|
||||
}
|
||||
|
||||
/// The command, prompt, drawing (and its revision) and view that the objects under the cursor
|
||||
/// were listed for: when any of them changes, the list and the Shift+Space highlight are stale.
|
||||
type CycleKey = (Option<String>, Option<String>, u64, u64, Vec2, f64);
|
||||
|
||||
impl CanvasState {
|
||||
/// The active MSPACE viewport's contents when they are drawn apart from the layout's list
|
||||
/// (see [`VpContent`]): where they are on paper at the viewport's current view, and how many
|
||||
@@ -137,6 +151,11 @@ impl CanvasState {
|
||||
self.vp.key.and(self.vp.paper_bounds()).map(|b| (b, self.vp.builds))
|
||||
}
|
||||
|
||||
/// The rows of the open selection cycling list (screen rectangle, object), top to bottom.
|
||||
pub fn cycle_rows(&self) -> Vec<(Rect, Handle)> {
|
||||
self.cycle.as_ref().map(|c| c.rows.iter().copied().zip(c.items.iter().map(|(h, _, _)| *h)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether a rebuild for the current view is still running in the background (the canvas
|
||||
/// shows the previous list until it lands).
|
||||
pub fn building(&self) -> bool {
|
||||
@@ -170,6 +189,22 @@ pub struct SnapCycle {
|
||||
index: usize,
|
||||
}
|
||||
|
||||
/// The objects under a click that selection cycling lets the user choose between: where the click
|
||||
/// was, each object with its row's label (type and layer), Shift held, and the rows as drawn.
|
||||
#[derive(Clone, Debug)]
|
||||
struct CycleList {
|
||||
at: Pos2,
|
||||
items: Vec<(Handle, String, String)>,
|
||||
shift: bool,
|
||||
rows: Vec<Rect>,
|
||||
}
|
||||
|
||||
impl CycleList {
|
||||
fn row_at(&self, p: Pos2) -> Option<Handle> {
|
||||
self.rows.iter().zip(&self.items).find(|(r, _)| r.contains(p)).map(|(_, (h, _, _))| *h)
|
||||
}
|
||||
}
|
||||
|
||||
/// Primitives of `list` sorted by entity handle, built on the first highlight after each list
|
||||
/// rebuild. Highlighting the hovered entity used to scan every primitive on every frame: ~2.5 ms
|
||||
/// per frame on a 270k-entity drawing, whether or not anything changed.
|
||||
@@ -1588,10 +1623,25 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
app.canvas.drag_window = false;
|
||||
}
|
||||
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.
|
||||
if inside
|
||||
&& pressed_primary
|
||||
&& !middle_down
|
||||
&& !cycled
|
||||
&& let Some(p) = app.canvas.cursor
|
||||
{
|
||||
if app.session.prompting() {
|
||||
@@ -1630,6 +1680,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
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)
|
||||
@@ -1688,6 +1739,22 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
app.canvas.hover = None;
|
||||
app.canvas.hover_at = None;
|
||||
}
|
||||
// The open selection cycling list highlights the object of the row under the pointer.
|
||||
if app.canvas.cycle.is_some() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.canvas.cycle = None;
|
||||
}
|
||||
if let Some(c) = &app.canvas.cycle {
|
||||
app.canvas.hover = hover_pos.and_then(|hp| c.row_at(hp));
|
||||
app.canvas.hover_at = None;
|
||||
}
|
||||
shift_space_cycle(app, ui, hover_pos.filter(|_| inside), raw_world);
|
||||
if inside && hover_pos != app.canvas.cycle_badge_at {
|
||||
app.canvas.cycle_badge = raw_world.is_some_and(|w| app.session.cycling_badge(w));
|
||||
app.canvas.cycle_badge_at = hover_pos;
|
||||
} else if !inside {
|
||||
app.canvas.cycle_badge = false;
|
||||
app.canvas.cycle_badge_at = None;
|
||||
}
|
||||
|
||||
// ---------- paint ----------
|
||||
painter.rect_filled(rect, 0.0, t.canvas);
|
||||
@@ -1944,6 +2011,9 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
ui.ctx().set_cursor_icon(if app.ui.system_cursor { egui::CursorIcon::Crosshair } else { egui::CursorIcon::None });
|
||||
if let Some(hp) = hover_pos {
|
||||
draw_crosshair(app, &painter, rect, hp, selecting);
|
||||
if app.canvas.cycle_badge && app.canvas.cycle.is_none() {
|
||||
cycle_badge(&painter, hp);
|
||||
}
|
||||
}
|
||||
if app.session.settings.dynmode
|
||||
&& let Some(hp) = hover_pos
|
||||
@@ -1956,6 +2026,8 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Over the crosshair, so the row under the pointer reads clearly.
|
||||
cycle_list(app, &painter);
|
||||
// No continuous repaint while hovering: input events repaint by themselves. Repainting every
|
||||
// frame kept a vsync swapchain's queue full (every frame waited behind queued ones) and would
|
||||
// spin the CPU with the low-latency present mode (`gpu::surface_config`).
|
||||
@@ -2016,6 +2088,119 @@ fn draw_tiles(app: &CadApp, ui: &egui::Ui, tiles: &[(Rect, cadcraft_engine::Mode
|
||||
}
|
||||
}
|
||||
|
||||
/// A click while selection cycling's list is open picks the row under it (anywhere else just
|
||||
/// closes the list); otherwise a click over overlapping objects that a pick would choose between
|
||||
/// opens the list (SELECTIONCYCLING 2). True when the click was used up here.
|
||||
fn cycle_click(app: &mut CadApp, xf: &Xf, hover: Option<Pos2>, world: Option<Vec2>, shift: bool) -> bool {
|
||||
// Only where a click picks objects; a click that places a point goes through.
|
||||
let picks = app.session.click_picks();
|
||||
if let Some((_, hs, i)) = app.canvas.hover_cycle.take()
|
||||
&& picks
|
||||
&& let Some(h) = hs.get(i % hs.len().max(1))
|
||||
{
|
||||
if let Err(e) = app.session.pick_cycled(*h, shift) {
|
||||
app.session.echo(e.to_string());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if let Some(list) = app.canvas.cycle.take() {
|
||||
let Some(h) = hover.and_then(|hp| list.row_at(hp)).filter(|_| picks) else { return picks };
|
||||
if let Err(e) = app.session.pick_cycled(h, list.shift) {
|
||||
app.session.echo(e.to_string());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let (Some(hp), Some(w)) = (hover, world) else { return false };
|
||||
if crate::dyngrips::at(app, xf, hover).is_some() || grip_at(app, xf, hover).is_some() {
|
||||
return false;
|
||||
}
|
||||
let hs = app.session.cycling_candidates(w);
|
||||
let Ok(d) = app.session.doc() else { return false };
|
||||
let items: Vec<_> =
|
||||
hs.into_iter().filter_map(|h| d.entity(h).map(|e| (h, crate::i18n::t(e.kind.type_name()).to_string(), e.common.layer.clone()))).collect();
|
||||
if items.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
app.canvas.cycle = Some(CycleList { at: hp, items, shift, rows: Vec::new() });
|
||||
true
|
||||
}
|
||||
|
||||
/// Shift+Space over overlapping objects highlights the next one under the cursor, and a click
|
||||
/// picks the highlighted one, whether selection cycling is on or not; moving the cursor off the
|
||||
/// pick box where it started ends it.
|
||||
fn shift_space_cycle(app: &mut CadApp, ui: &egui::Ui, hover: Option<Pos2>, world: Option<Vec2>) {
|
||||
let pick_px = (app.session.settings.pickbox.max(1.0) * 1.5) as f32;
|
||||
let (Some(hp), Some(w)) = (hover, world) else {
|
||||
app.canvas.hover_cycle = None;
|
||||
return;
|
||||
};
|
||||
if app.canvas.hover_cycle.as_ref().is_some_and(|(a, _, _)| a.distance(hp) > pick_px) {
|
||||
app.canvas.hover_cycle = None;
|
||||
}
|
||||
let pressed = ui
|
||||
.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::Space, pressed: true, modifiers, .. } if modifiers.shift)));
|
||||
if pressed {
|
||||
// With the list open Shift+Space does nothing, and isn't Enter either.
|
||||
let next = match app.canvas.hover_cycle.take() {
|
||||
_ if app.canvas.cycle.is_some() => None,
|
||||
Some((a, hs, i)) => Some((a, hs, i + 1)),
|
||||
None => Some((hp, app.session.pick_candidates(w), 1)).filter(|(_, hs, _)| hs.len() > 1),
|
||||
};
|
||||
if next.is_some() || app.canvas.cycle.is_some() {
|
||||
// Space is Enter at the command line; this one only steps the highlight.
|
||||
ui.ctx().input_mut(|i| {
|
||||
i.events.retain(|e| !matches!(e, egui::Event::Key { key: egui::Key::Space, .. }) && !matches!(e, egui::Event::Text(t) if t == " "))
|
||||
});
|
||||
}
|
||||
app.canvas.hover_cycle = next;
|
||||
}
|
||||
if let Some((_, hs, i)) = &app.canvas.hover_cycle {
|
||||
app.canvas.hover = hs.get(i % hs.len().max(1)).copied();
|
||||
app.canvas.hover_at = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Selection cycling's badge beside the cursor: two overlapping squares, objects overlap here.
|
||||
fn cycle_badge(p: &egui::Painter, at: Pos2) {
|
||||
let t = Tokens::get();
|
||||
for off in [vec2(14.0, -22.0), vec2(19.0, -17.0)] {
|
||||
let r = Rect::from_min_size(at + off, vec2(9.0, 9.0));
|
||||
p.rect_filled(r, 1.0, t.cmd_bg);
|
||||
p.rect_stroke(r, 1.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
|
||||
/// The selection cycling list beside the click that opened it: a row per object, its type and
|
||||
/// layer, the row under the pointer highlighted; the rows are kept for clicks.
|
||||
fn cycle_list(app: &mut CadApp, p: &egui::Painter) {
|
||||
let Some(list) = app.canvas.cycle.as_mut() else { return };
|
||||
let t = Tokens::get();
|
||||
let font = crate::theme::small();
|
||||
let galleys: Vec<_> = list
|
||||
.items
|
||||
.iter()
|
||||
.map(|(_, ty, layer)| (p.layout_no_wrap(ty.clone(), font.clone(), t.text), p.layout_no_wrap(layer.clone(), font.clone(), t.text_dim)))
|
||||
.collect();
|
||||
let row_h = galleys.iter().map(|(a, _)| a.size().y).fold(12.0, f32::max) + 6.0;
|
||||
let ty_w = galleys.iter().map(|(a, _)| a.size().x).fold(40.0, f32::max);
|
||||
let layer_w = galleys.iter().map(|(_, b)| b.size().x).fold(20.0, f32::max);
|
||||
let w = ty_w + layer_w + 36.0;
|
||||
let m = Rect::from_min_size(list.at + vec2(14.0, 14.0), vec2(w, row_h * galleys.len() as f32 + 4.0));
|
||||
p.rect_filled(m, 3.0, t.cmd_bg);
|
||||
p.rect_stroke(m, 3.0, Stroke::new(1.0, t.cmd_border), egui::StrokeKind::Inside);
|
||||
let hover = app.canvas.hover;
|
||||
list.rows.clear();
|
||||
for (i, ((ty, layer), (h, _, _))) in galleys.into_iter().zip(&list.items).enumerate() {
|
||||
let row = Rect::from_min_size(pos2(m.left() + 2.0, m.top() + 2.0 + row_h * i as f32), vec2(w - 4.0, row_h));
|
||||
if hover == Some(*h) {
|
||||
p.rect_filled(row, 2.0, t.cmd_keyword_hover);
|
||||
}
|
||||
p.galley(pos2(row.left() + 8.0, row.center().y - ty.size().y / 2.0), ty, t.text);
|
||||
p.galley(pos2(row.left() + 20.0 + ty_w, row.center().y - layer.size().y / 2.0), layer, t.text_dim);
|
||||
list.rows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
fn tooltip(p: &egui::Painter, at: Pos2, text: &str) {
|
||||
let galley = p.layout_no_wrap(text.to_string(), crate::theme::small(), Color32::from_rgb(0x20, 0x20, 0x20));
|
||||
let r = Rect::from_min_size(at, galley.size() + vec2(8.0, 4.0));
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Selection cycling on the canvas (#1143, #1145): with SELECTIONCYCLING 2 a click over
|
||||
//! overlapping objects lists them and a click on a row selects that object; Shift+Space steps the
|
||||
//! highlight through them and a click selects the highlighted one.
|
||||
|
||||
use cadcraft_doc::Handle;
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_geom::Vec2;
|
||||
use cadcraft_ui_egui::{CadApp, Services};
|
||||
use egui::{Modifiers, Pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
/// Two lines overlapping from x = 0 to 10 (the second on top), zoomed to fit; the lines'
|
||||
/// handles.
|
||||
fn harness(selectioncycling: i64) -> (Harness<'static, CadApp>, Vec<Handle>) {
|
||||
let mut session = Session::new();
|
||||
session.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
|
||||
session.execute("line", &json!({ "points": [[0, 0], [20, 0]] })).unwrap();
|
||||
session.execute("setvar", &json!({ "name": "SELECTIONCYCLING", "value": selectioncycling })).unwrap();
|
||||
session.set_selection(Vec::new());
|
||||
let hs: Vec<Handle> = session.doc().unwrap().model.iter().map(|e| e.handle).collect();
|
||||
let mut app = CadApp::new(session, Services::default());
|
||||
app.canvas.zoom_pending = true;
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|
||||
|ui, app: &mut CadApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
(h, hs)
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'static, CadApp>, at: Pos2) {
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn overlap(h: &Harness<'static, CadApp>) -> Pos2 {
|
||||
h.state().canvas.xf.unwrap().to_screen(Vec2::new(5.0, 0.0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_over_overlapping_objects_lists_them_and_a_row_picks_one() {
|
||||
let (mut h, hs) = harness(2);
|
||||
let at = overlap(&h);
|
||||
click(&mut h, at);
|
||||
let rows = h.state().canvas.cycle_rows();
|
||||
assert_eq!(rows.iter().map(|(_, e)| *e).collect::<Vec<_>>(), vec![hs[1], hs[0]], "a row per line, the one on top first");
|
||||
assert!(h.state().session.selection().is_empty(), "the click opens the list instead of picking");
|
||||
// Hovering the second row highlights its line; clicking it selects only that line.
|
||||
let second = rows[1].0.center();
|
||||
h.event(egui::Event::PointerMoved(second));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().canvas.hover, Some(hs[0]));
|
||||
click(&mut h, second);
|
||||
assert_eq!(h.state().session.selection(), vec![hs[0]]);
|
||||
assert!(h.state().canvas.cycle_rows().is_empty(), "picking a row closes the list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_selectioncycling_0_a_click_picks_the_top_object() {
|
||||
let (mut h, hs) = harness(0);
|
||||
let at = overlap(&h);
|
||||
click(&mut h, at);
|
||||
assert!(h.state().canvas.cycle_rows().is_empty());
|
||||
assert_eq!(h.state().session.selection(), vec![hs[1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_space_steps_the_highlight_and_a_click_picks_it() {
|
||||
// Selection cycling off (AutoCAD's initial -2): Shift+Space works as its temporary override.
|
||||
let (mut h, hs) = harness(-2);
|
||||
let at = overlap(&h);
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().canvas.hover, Some(hs[1]));
|
||||
h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT });
|
||||
h.event(egui::Event::Text(" ".into()));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().canvas.hover, Some(hs[0]), "Shift+Space highlights the line underneath");
|
||||
click(&mut h, at);
|
||||
assert_eq!(h.state().session.selection(), vec![hs[0]]);
|
||||
}
|
||||
|
||||
fn type_line(h: &mut Harness<'static, CadApp>, text: &str) {
|
||||
h.event(egui::Event::Text(text.into()));
|
||||
h.event(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// The start point LINE's next prompt measures from: where its first click landed.
|
||||
fn line_started_at(h: &Harness<'static, CadApp>) -> Option<Vec2> {
|
||||
assert_eq!(h.state().session.running.as_ref().map(|r| r.id.as_str()), Some("line"));
|
||||
h.state().session.current_prompt().and_then(|p| p.base)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_shift_space_a_command_still_gets_its_first_click() {
|
||||
let (mut h, _) = harness(-2);
|
||||
let at = overlap(&h);
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
h.run_steps(2);
|
||||
h.event(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT });
|
||||
h.event(egui::Event::Text(" ".into()));
|
||||
h.run_steps(2);
|
||||
type_line(&mut h, "L");
|
||||
click(&mut h, at);
|
||||
let start = line_started_at(&h).expect("the click placed LINE's first point");
|
||||
assert!(start.dist(Vec2::new(5.0, 0.0)) < 0.5, "{start:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_closes_the_list_and_gets_its_first_click() {
|
||||
let (mut h, _) = harness(2);
|
||||
let at = overlap(&h);
|
||||
click(&mut h, at);
|
||||
assert_eq!(h.state().canvas.cycle_rows().len(), 2);
|
||||
type_line(&mut h, "L");
|
||||
assert!(h.state().canvas.cycle_rows().is_empty(), "starting a command closes the list");
|
||||
let elsewhere = h.state().canvas.xf.unwrap().to_screen(Vec2::new(5.0, 8.0));
|
||||
click(&mut h, elsewhere);
|
||||
let start = line_started_at(&h).expect("the click placed LINE's first point");
|
||||
assert!(start.dist(Vec2::new(5.0, 8.0)) < 0.5, "{start:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_an_erase_a_row_never_selects_an_object_that_is_gone() {
|
||||
let (mut h, hs) = harness(2);
|
||||
let at = overlap(&h);
|
||||
click(&mut h, at);
|
||||
let rows = h.state().canvas.cycle_rows();
|
||||
assert_eq!(rows[0].1, hs[1]);
|
||||
// The top line goes away (as by undo or ERASE elsewhere); then the row that listed it is clicked.
|
||||
h.state_mut().run("erase", json!({ "handles": [hs[1].hex()] })).unwrap();
|
||||
h.run_steps(1);
|
||||
click(&mut h, rows[0].0.center());
|
||||
let sel = h.state().session.selection();
|
||||
let d = h.state().session.doc().unwrap();
|
||||
assert!(sel.iter().all(|s| d.entity(*s).is_some()), "{sel:?}");
|
||||
assert!(!sel.contains(&hs[1]));
|
||||
}
|
||||
+1
-1
@@ -60,7 +60,7 @@ or allow clicks and drag (2, default), saved in the user profile. Drag-only mode
|
||||
|---|---|---|---|
|
||||
| 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 |
|
||||
| U5 | Grip menus open on right-click only (no hover menu, no Ctrl to cycle the options); no multifunctional grips on dimensions, MLEADER, hatches or text; Add Vertex on an arc segment straightens it. Hot-grip options (Base point, Copy, Undo, Reference, eXit), multiple hot grips and the polyline/line/arc grip menus landed (#425) | Low-medium | 2–4 |
|
||||
| U6 | Selection: lasso; selection cycling (a list of overlapping objects, SELECTIONCYCLING); the CLass keyword; isolation saved with the drawing (OBJECTISOLATIONMODE 1) and the status-bar isolate button. Press-drag windows (#63), the W, C, F, WP and CP keywords (#226), BOX, AUto, SIngle, Multiple, Group, Add, Remove and Undo, SELECTSIMILAR, and ISOLATEOBJECTS/HIDEOBJECTS/UNISOLATEOBJECTS (#428) landed | Medium | 3–5 |
|
||||
| U6 | Selection: lasso; the CLass keyword; isolation saved with the drawing (OBJECTISOLATIONMODE 1) and the status-bar isolate button. Press-drag windows (#63), the W, C, F, WP and CP keywords (#226), BOX, AUto, SIngle, Multiple, Group, Add, Remove and Undo, SELECTSIMILAR, ISOLATEOBJECTS/HIDEOBJECTS/UNISOLATEOBJECTS (#428) and selection cycling (#1143) landed | Medium | 3–5 |
|
||||
| U7 | Right-click menus: no in-place layout tab rename or Move or Copy dialog, no Select All Layouts, no Paste to command line; palette menus are on the Layers rows and the Properties background only. The command menu (held right-click: Enter, Cancel, Recent Input, options, Snap Overrides, Pan/Zoom), the selection menu with object edits, command line, drawing tab, layout tab and palette menus landed (#512) | Low | 1–2 |
|
||||
| U10 | SETVAR covers about 150 of ~1,000 variables (UNDO options, MREDO and UNDOCTL, and header variables such as CIRCLERAD, OFFSETDIST, POLYSIDES, HPNAME and the dimension variables in SETVAR ? landed, #461; typing a bare variable name as a command runs SETVAR on it) | Low-medium | 3–5 |
|
||||
| U11 | MVIEW Restore (a saved viewport configuration into a layout); VIEW Orthographic, UCS and background settings; view twist is kept but not shown (Zoom Realtime and Dynamic, ZOOMFACTOR, named views (VIEW) and tiled model viewports with saved configurations (VPORTS) landed in #463) | Low | 1–2 |
|
||||
|
||||
+2
-1
@@ -129,7 +129,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM.
|
||||
| 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 |
|
||||
| Lasso | yes | **no** |
|
||||
| Selection cycling (SELECTIONCYCLING) | yes | toggle nothing reads |
|
||||
| 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) |
|
||||
| PICKADD, PICKFIRST, PICKAUTO | yes | PICKADD (Shift), PICKFIRST |
|
||||
| Stability of outlined fill highlights | yes | closed contours keep the same start, direction and ordering on repaints and GPU rebuilds; dashed filled glyphs, hatch/solid regions, arrowheads and wipeout outlines no longer shift with pointer movement |
|
||||
@@ -204,6 +204,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Filled-region highlight boundaries are traversed in deterministic coordinate order, fixing dash-phase jumps on CPU hover/selection redraws and GPU highlight rebuilds; CPU dash-position, pixel and GPU mesh regressions |
|
||||
| 2026-10-14 | minor | Selection cycling (#1143): the list and badge of SELECTIONCYCLING, Shift+Space through overlapping objects (#1145) |
|
||||
| 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-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 |
|
||||
|
||||
Reference in new issue
Block a user