feat(select): Select Similar + Quick Select panel (#28)

Two ways to extend the current selection by entity type + layer:

* `Select Similar` (right-click → Select Similar, or `SELECTSIMILAR` /
  `SELSIM`): for every (type, layer) pair in the current selection, add
  every entity in the active layout that matches the pair. The seed
  selection stays selected; matches already selected are not
  double-counted.
* `Quick Select` (right-click → Quick Select…, or `QSELECT` / `QS`):
  opens a centred floating panel with Object-type and Layer pick_lists
  (populated only with values that actually exist in the active layout)
  and an "Append to current selection" checkbox. `(Any)` in either slot
  skips that filter. Apply replaces the selection (or extends it with
  Append on) and reports the match count on the command line. Cancel,
  Esc, and outside-click all dismiss without applying. When opened with
  a selection present, the panel pre-fills both filters from the first
  selected entity so QSelect doubles as an editable Select Similar.

Scoped to entities owned by the active layout's block-record so a paper
layout's QSELECT never reaches into model-space entities (and vice
versa). Entity-type names live in a single `entity_type_name` helper in
`entities::traits` to keep the UI strings and the filter keys aligned.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-05-29 20:30:56 +03:00
commit 9db206f310
6 changed files with 436 additions and 0 deletions

View file

@ -910,6 +910,14 @@ impl OpenCADStudio {
iced::widget::Space::new().width(0).height(0).into()
};
let qselect_layer: Element<'_, Message> = if let Some(state) = &self.qselect {
let types = tab.scene.entity_type_names_in_layout();
let layers = tab.scene.entity_layers_in_layout();
qselect_overlay(state, &types, &layers)
} else {
iced::widget::Space::new().width(0).height(0).into()
};
let open_progress_layer: Element<'_, Message> = if let Some(p) = &self.opening {
crate::ui::open_progress::view(p, iced::time::Instant::now())
} else {
@ -923,6 +931,7 @@ impl OpenCADStudio {
scale_layer,
dropdown_layer,
layout_ctx_layer,
qselect_layer,
open_progress_layer,
]
.into()
@ -1384,11 +1393,13 @@ fn viewport_context_menu_overlay(
Message::Command("COPY".to_string()),
));
items.push(sep());
items.push(item("Select Similar".to_string(), Message::SelectSimilar));
}
items.push(item(
"Select All".to_string(),
Message::Command("SELECTALL".to_string()),
));
items.push(item("Quick Select...".to_string(), Message::QSelectOpen));
items.push(item(
"Zoom Extents".to_string(),
Message::Command("ZOOM".to_string()),
@ -1503,6 +1514,147 @@ fn layout_context_menu_overlay(name: &str) -> Element<'_, Message> {
stack![catcher, positioned].into()
}
// ── Quick Select panel ─────────────────────────────────────────────────────
const QSELECT_ANY: &str = "(Any)";
/// Floating panel for the Quick Select feature. Two pick_lists (object
/// type, layer) filter the candidate set down to entities matching both;
/// "(Any)" in either slot skips that filter. The panel returns to the
/// stored state on every render, so editing one filter doesn't reset the
/// other. Outside-click and Cancel both dismiss without applying.
fn qselect_overlay<'a>(
state: &'a crate::app::QSelectState,
types: &[&'static str],
layers: &[String],
) -> Element<'a, Message> {
use iced::widget::{checkbox, pick_list};
const BG: Color = Color { r: 0.12, g: 0.12, b: 0.12, a: 0.98 };
const BORDER: Color = Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0 };
const TEXT: Color = Color { r: 0.88, g: 0.88, b: 0.88, a: 1.0 };
const BTN_OK: Color = Color { r: 0.22, g: 0.42, b: 0.68, a: 1.0 };
const BTN_OK_HOV: Color = Color { r: 0.30, g: 0.52, b: 0.80, a: 1.0 };
const BTN_BG: Color = Color { r: 0.22, g: 0.22, b: 0.22, a: 1.0 };
const BTN_HOV: Color = Color { r: 0.30, g: 0.30, b: 0.30, a: 1.0 };
let mut type_options: Vec<String> = vec![QSELECT_ANY.to_string()];
type_options.extend(types.iter().map(|s| (*s).to_string()));
let mut layer_options: Vec<String> = vec![QSELECT_ANY.to_string()];
layer_options.extend(layers.iter().cloned());
let type_sel = state
.type_filter
.clone()
.unwrap_or_else(|| QSELECT_ANY.to_string());
let layer_sel = state
.layer_filter
.clone()
.unwrap_or_else(|| QSELECT_ANY.to_string());
let label = |s: &'static str| text(s).size(12).color(TEXT).width(iced::Length::Fixed(90.0));
let btn = |lbl: &'static str, msg: Message, base: Color, hov: Color| {
button(text(lbl).size(12).color(TEXT))
.on_press(msg)
.style(move |_: &Theme, st| button::Style {
background: Some(Background::Color(
if matches!(st, button::Status::Hovered | button::Status::Pressed) {
hov
} else {
base
},
)),
text_color: TEXT,
border: Border {
color: BORDER,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
})
.padding([4, 14])
};
let panel_body = column![
text("Quick Select").size(14).color(TEXT),
Space::new().height(10),
row![
label("Object type:"),
pick_list(type_options, Some(type_sel), |s: String| {
if s == QSELECT_ANY {
Message::QSelectSetType(None)
} else {
Message::QSelectSetType(Some(s))
}
})
.width(Fill),
]
.align_y(iced::Alignment::Center)
.spacing(8),
Space::new().height(6),
row![
label("Layer:"),
pick_list(layer_options, Some(layer_sel), |s: String| {
if s == QSELECT_ANY {
Message::QSelectSetLayer(None)
} else {
Message::QSelectSetLayer(Some(s))
}
})
.width(Fill),
]
.align_y(iced::Alignment::Center)
.spacing(8),
Space::new().height(10),
row![
checkbox(state.append)
.on_toggle(Message::QSelectSetAppend)
.size(14),
Space::new().width(6),
text("Append to current selection").size(12).color(TEXT),
]
.align_y(iced::Alignment::Center),
Space::new().height(14),
row![
Space::new().width(Fill),
btn("Cancel", Message::QSelectClose, BTN_BG, BTN_HOV),
Space::new().width(8),
btn("Apply", Message::QSelectApply, BTN_OK, BTN_OK_HOV),
]
.align_y(iced::Alignment::Center),
]
.spacing(0);
let panel = container(panel_body)
.padding(16)
.width(iced::Length::Fixed(360.0))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BG)),
border: Border {
color: BORDER,
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
});
// Outside-click catcher — fills the whole screen, sits below the
// panel. The panel itself is rendered above and absorbs its own
// clicks via standard widget event handling.
let catcher = mouse_area(
container(iced::widget::Space::new().width(Fill).height(Fill))
.width(Fill)
.height(Fill),
)
.on_press(Message::QSelectClose)
.on_right_press(Message::QSelectClose);
let centered = container(iced::widget::opaque(panel))
.center(Fill);
stack![catcher, centered].into()
}
/// Content for the floating "Unsaved Changes" OS window.
const SAVE_FORMAT_OPTIONS: &[&str] = &[
"DWG 2018", "DWG 2013", "DWG 2010", "DWG 2007", "DWG 2004", "DWG 2000", "DWG R14", "DXF 2018",