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:
authored and GitHub committed 2026-10-19 02:54:16 -04:00
1 parent 5f390e6e6a
commit 6d15a40f43
8 files changed
+522 -14

No files matched your search

+81 -12
View File
@@ -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() {
+15
View File
@@ -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 {
+11
View File
@@ -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) => {}
+81
View File
@@ -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");
}
+185
View File
@@ -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));
+146
View File
@@ -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
View File
@@ -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
View File
@@ -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 |