feat(qselect): per-property filter with operator + value (#28)

Quick Select used to filter only on Object Type and Layer. The classic
QSELECT model — pick a type, pick one of its properties, compare with a
typed value via a logical operator — is much more general: a Line entity
can now be filtered on Start X, Length, Angle, Delta Y, …, and any
entity on the common Layer / Color / Linetype / Lineweight.

Panel layout: Object type → Property → Operator → Value → Append. The
Property dropdown is type-aware: it always lists the four common
properties; picking a specific Object type appends that type's
`geometry_properties` rows, sampled from the first entity of that type
in the active layout (so `Start X`, `Radius`, `Pattern Angle`, … only
appear when their entity type is selected). Changing the Object type
drops the Property when the new type no longer offers it.

Operators: `= Equals`, `!= Not equal`, `> Greater than`, `< Less than`,
`* Any value`. Eq/Neq do case-insensitive string compare against the
canonical property string (so "ByLayer" matches "bylayer"); Gt/Lt parse
both sides as `f64` and reject anything non-numeric; Any skips the
value test entirely. The Value text field is disabled when no Property
is picked or the operator is Any. Append behaves the same as before.

The new value reader is hand-rolled for the four common properties
(layer name, color formatted as `ByLayer`/`ByBlock`/`<aci>`/`r,g,b`,
linetype name, lineweight formatted as `0.30mm`) and falls back through
`geometry_properties()` for type-specific fields — extracting the
property's value out of the `PropValue` variant so EditText / Choice /
ColorChoice all canonicalise the same way the panel will display them.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-29 21:09:56 +03:00
1 parent 9db206f310
commit 7d9ac435cb
4 files changed
+326 -60

No files matched your search

+69 -6
View File
@@ -56,13 +56,70 @@ pub struct GripPendingValue {
pub label: &'static str,
}
/// Open Quick Select panel state. `type_filter` / `layer_filter` of
/// `None` match every entity in that field; otherwise the filter is the
/// literal name to compare against.
/// Operator the Quick Select filter applies between an entity's
/// property value and the user-typed test value.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum QSelectOp {
/// `*Any value` — the entity matches as long as the type filter
/// passes; the value column is ignored.
Any,
Eq,
Neq,
Gt,
Lt,
}
impl std::fmt::Display for QSelectOp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
QSelectOp::Any => "* Any value",
QSelectOp::Eq => "= Equals",
QSelectOp::Neq => "!= Not equal",
QSelectOp::Gt => "> Greater than",
QSelectOp::Lt => "< Less than",
};
f.write_str(s)
}
}
/// One row in the Quick Select Properties pick_list. `field` is the
/// stable identifier (`"layer"`, `"start_x"`, …) used to look up the
/// value on each candidate entity; `label` is the human label rendered
/// in the dropdown. Equality only compares `field` so the pick_list
/// round-trips selection correctly even when labels are duplicated.
#[derive(Clone, Debug)]
pub struct QSelectPropertyChoice {
pub field: String,
pub label: String,
}
impl PartialEq for QSelectPropertyChoice {
fn eq(&self, other: &Self) -> bool {
self.field == other.field
}
}
impl Eq for QSelectPropertyChoice {}
impl std::fmt::Display for QSelectPropertyChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.label)
}
}
/// Open Quick Select panel state. The filter is one
/// `(type, property, op, value)` row plus an Append-to-current-selection
/// toggle, mirroring the classic QSELECT dialog: the panel filters
/// candidate entities (entire layout) by type, then by the chosen
/// property compared to the typed value using the operator.
#[derive(Clone, Debug)]
pub struct QSelectState {
/// `None` = "(Any type)".
pub type_filter: Option<String>,
pub layer_filter: Option<String>,
/// `None` = no property filter; the type filter alone applies.
pub property: Option<QSelectPropertyChoice>,
pub operator: QSelectOp,
pub value: String,
pub append: bool,
}
use crate::snap::Snapper;
@@ -652,8 +709,14 @@ pub enum Message {
QSelectClose,
/// Type filter — `None` means "any type".
QSelectSetType(Option<String>),
/// Layer filter — `None` means "any layer".
QSelectSetLayer(Option<String>),
/// Property to compare. `None` means "no property filter — just type
/// filter applies"; the operator and value fields are ignored in
/// that case.
QSelectSetProperty(Option<QSelectPropertyChoice>),
/// Comparison operator.
QSelectSetOperator(QSelectOp),
/// Compare-against value (free-text input).
QSelectSetValue(String),
/// Append-to-current-selection toggle.
QSelectSetAppend(bool),
/// Apply the current filter and close the panel.
+40 -10
View File
@@ -3392,22 +3392,23 @@ impl OpenCADStudio {
Message::QSelectOpen => {
let i = self.active_tab;
self.tabs[i].scene.selection.borrow_mut().context_menu = None;
// Seed filters from the first selected entity (if any) so
// a right-click → Quick Select on a known object pre-fills
// the panel — same idea as Select Similar but exposes the
// pairing for editing.
// Seed the type filter from the first selected entity so a
// right-click → Quick Select on a known object opens the
// panel pre-tuned to that entity's type. Property defaults
// to "(Any property)" so the user immediately picks what
// they want to compare.
let mut type_filter: Option<String> = None;
let mut layer_filter: Option<String> = None;
if let Some(&h) = self.tabs[i].scene.selected.iter().next() {
if let Some(e) = self.tabs[i].scene.document.get_entity(h) {
use crate::entities::traits::entity_type_name;
type_filter = Some(entity_type_name(e).to_string());
layer_filter = Some(e.as_entity().layer().to_string());
}
}
self.qselect = Some(crate::app::QSelectState {
type_filter,
layer_filter,
property: None,
operator: crate::app::QSelectOp::Eq,
value: String::new(),
append: false,
});
Task::none()
@@ -3420,14 +3421,41 @@ impl OpenCADStudio {
Message::QSelectSetType(t) => {
if let Some(state) = self.qselect.as_mut() {
// Drop the property when it no longer applies to the
// chosen type: type-specific fields like `start_x`
// would otherwise stay selected but never match.
let kept_property = state.property.clone().and_then(|p| {
let i = self.active_tab;
let props = self.tabs[i].scene.qselect_properties(t.as_deref());
if props.iter().any(|(f, _)| f == &p.field) {
Some(p)
} else {
None
}
});
state.type_filter = t;
state.property = kept_property;
}
Task::none()
}
Message::QSelectSetLayer(l) => {
Message::QSelectSetProperty(p) => {
if let Some(state) = self.qselect.as_mut() {
state.layer_filter = l;
state.property = p;
}
Task::none()
}
Message::QSelectSetOperator(op) => {
if let Some(state) = self.qselect.as_mut() {
state.operator = op;
}
Task::none()
}
Message::QSelectSetValue(v) => {
if let Some(state) = self.qselect.as_mut() {
state.value = v;
}
Task::none()
}
@@ -3446,7 +3474,9 @@ impl OpenCADStudio {
let i = self.active_tab;
let matched = self.tabs[i].scene.qselect(
state.type_filter.as_deref(),
state.layer_filter.as_deref(),
state.property.as_ref().map(|p| p.field.as_str()),
state.operator,
&state.value,
state.append,
);
self.command_line
+69 -24
View File
@@ -912,8 +912,10 @@ impl OpenCADStudio {
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)
let properties = tab
.scene
.qselect_properties(state.type_filter.as_deref());
qselect_overlay(state, &types, &properties)
} else {
iced::widget::Space::new().width(0).height(0).into()
};
@@ -1516,17 +1518,19 @@ fn layout_context_menu_overlay(name: &str) -> Element<'_, Message> {
// ── Quick Select panel ─────────────────────────────────────────────────────
const QSELECT_ANY: &str = "(Any)";
const QSELECT_ANY_TYPE: &str = "(Any type)";
const QSELECT_ANY_PROP: &str = "(Any property)";
/// 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.
/// Floating panel for the Quick Select feature. Single-row filter:
/// object type → property → operator → value, plus an "Append to current
/// selection" checkbox. The property dropdown is type-aware — Common
/// properties (Layer, Color, Linetype, Lineweight) are always shown;
/// picking a specific Object type adds that type's `geometry_properties`
/// fields (Start X, Length, Radius, …) so type-specific filtering works.
fn qselect_overlay<'a>(
state: &'a crate::app::QSelectState,
types: &[&'static str],
layers: &[String],
properties: &[(String, 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 };
@@ -1537,19 +1541,43 @@ fn qselect_overlay<'a>(
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()];
let mut type_options: Vec<String> = vec![QSELECT_ANY_TYPE.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 mut prop_options: Vec<crate::app::QSelectPropertyChoice> =
vec![crate::app::QSelectPropertyChoice {
field: String::new(),
label: QSELECT_ANY_PROP.to_string(),
}];
prop_options.extend(properties.iter().map(|(field, label)| {
crate::app::QSelectPropertyChoice {
field: field.clone(),
label: label.clone(),
}
}));
let op_options: Vec<crate::app::QSelectOp> = vec![
crate::app::QSelectOp::Eq,
crate::app::QSelectOp::Neq,
crate::app::QSelectOp::Gt,
crate::app::QSelectOp::Lt,
crate::app::QSelectOp::Any,
];
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());
.unwrap_or_else(|| QSELECT_ANY_TYPE.to_string());
let prop_sel = state.property.clone().unwrap_or(crate::app::QSelectPropertyChoice {
field: String::new(),
label: QSELECT_ANY_PROP.to_string(),
});
// The value field is disabled (visually de-emphasised; we still
// render the same widget) when no property is picked or the
// operator is "*Any value" — both of those skip the value test.
let value_enabled = state.property.is_some()
&& !matches!(state.operator, crate::app::QSelectOp::Any);
let label = |s: &'static str| text(s).size(12).color(TEXT).width(iced::Length::Fixed(90.0));
@@ -1575,13 +1603,18 @@ fn qselect_overlay<'a>(
.padding([4, 14])
};
let mut value_input = text_input("", &state.value).size(12);
if value_enabled {
value_input = value_input.on_input(Message::QSelectSetValue);
}
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 {
if s == QSELECT_ANY_TYPE {
Message::QSelectSetType(None)
} else {
Message::QSelectSetType(Some(s))
@@ -1593,18 +1626,30 @@ fn qselect_overlay<'a>(
.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)
label("Property:"),
pick_list(prop_options, Some(prop_sel), |p: crate::app::QSelectPropertyChoice| {
if p.field.is_empty() {
Message::QSelectSetProperty(None)
} else {
Message::QSelectSetLayer(Some(s))
Message::QSelectSetProperty(Some(p))
}
})
.width(Fill),
]
.align_y(iced::Alignment::Center)
.spacing(8),
Space::new().height(6),
row![
label("Operator:"),
pick_list(op_options, Some(state.operator), Message::QSelectSetOperator)
.width(Fill),
]
.align_y(iced::Alignment::Center)
.spacing(8),
Space::new().height(6),
row![label("Value:"), value_input,]
.align_y(iced::Alignment::Center)
.spacing(8),
Space::new().height(10),
row![
checkbox(state.append)
@@ -1627,7 +1672,7 @@ fn qselect_overlay<'a>(
let panel = container(panel_body)
.padding(16)
.width(iced::Length::Fixed(360.0))
.width(iced::Length::Fixed(400.0))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BG)),
border: Border {
+148 -20
View File
@@ -3763,18 +3763,24 @@ impl Scene {
added
}
/// Replaces (or extends, when `append` is true) the current selection
/// with every entity in the active layout that matches the optional
/// `type_name` and `layer` filters. A `None` filter means "any".
/// Returns the number of matching entities (i.e. the post-call
/// selection size if `append` was false, or the new total if it was
/// true and previously-selected matches are not double-counted).
/// Replaces (or extends, when `append` is true) the current
/// selection with every entity in the active layout that matches
/// the filter. Returns the number of newly-matching entities.
///
/// `type_name` of `None` means "any type". `property_field` of
/// `None` skips the property test (only the type filter applies).
/// The operator's `Any` variant also skips the property test.
/// Numeric operators (`Gt` / `Lt`) parse both sides as `f64` and
/// reject anything non-numeric.
pub fn qselect(
&mut self,
type_name: Option<&str>,
layer: Option<&str>,
property_field: Option<&str>,
op: crate::app::QSelectOp,
value: &str,
append: bool,
) -> usize {
use crate::app::QSelectOp;
use crate::entities::traits::entity_type_name;
if !append {
self.selected.clear();
@@ -3785,9 +3791,37 @@ impl Scene {
let Some(e) = self.document.get_entity(h) else {
continue;
};
let type_ok = type_name.is_none_or(|t| entity_type_name(e) == t);
let layer_ok = layer.is_none_or(|l| e.as_entity().layer() == l);
if type_ok && layer_ok {
if let Some(t) = type_name {
if entity_type_name(e) != t {
continue;
}
}
let prop_ok = match (property_field, op) {
(None, _) | (_, QSelectOp::Any) => true,
(Some(field), op) => {
let Some(actual) = self.entity_property_value(e, field) else {
continue;
};
match op {
QSelectOp::Eq => actual.eq_ignore_ascii_case(value),
QSelectOp::Neq => !actual.eq_ignore_ascii_case(value),
QSelectOp::Gt | QSelectOp::Lt => {
let (Ok(a), Ok(b)) =
(actual.parse::<f64>(), value.parse::<f64>())
else {
continue;
};
if matches!(op, QSelectOp::Gt) {
a > b
} else {
a < b
}
}
QSelectOp::Any => true,
}
}
};
if prop_ok {
self.selected.insert(h);
matched += 1;
}
@@ -3811,18 +3845,112 @@ impl Scene {
names.into_iter().collect()
}
/// Returns the sorted set of layer names referenced by entities in
/// the active layout. Used to populate the Quick Select "Layer"
/// dropdown.
pub fn entity_layers_in_layout(&self) -> Vec<String> {
let mut names: std::collections::BTreeSet<String> =
std::collections::BTreeSet::new();
for h in self.current_layout_entity_handles() {
if let Some(e) = self.document.get_entity(h) {
names.insert(e.as_entity().layer().to_string());
/// Returns the list of `(field, label)` pairs the Quick Select
/// "Properties" dropdown should show given the current type filter:
///
/// * Common properties (Layer, Color, Linetype, Lineweight) are
/// always included.
/// * When `type_name` names a specific entity type present in the
/// active layout, the first entity of that type contributes its
/// `geometry_properties()` rows (Start X, Length, Radius, …) so
/// type-specific filtering works.
pub fn qselect_properties(
&self,
type_name: Option<&str>,
) -> Vec<(String, String)> {
use crate::entities::traits::{entity_type_name, EntityTypeOps};
let mut out: Vec<(String, String)> = vec![
("layer".to_string(), "Layer".to_string()),
("color".to_string(), "Color".to_string()),
("linetype".to_string(), "Linetype".to_string()),
("lineweight".to_string(), "Lineweight".to_string()),
];
if let Some(t) = type_name {
let text_style_names: Vec<String> = self
.document
.text_styles
.iter()
.map(|s| s.name.clone())
.collect();
let sample = self
.current_layout_entity_handles()
.into_iter()
.filter_map(|h| self.document.get_entity(h))
.find(|e| entity_type_name(e) == t);
if let Some(sample) = sample {
if let Some(section) = sample.geometry_properties(&text_style_names) {
for prop in section.props {
// Skip rows that don't sensibly compare via
// `entity_property_value` (read-only labels are
// fine — users can still match against them).
out.push((prop.field.to_string(), prop.label.clone()));
}
}
}
}
names.into_iter().collect()
out
}
/// Reads a property value from an entity for QSELECT comparison.
/// Returns the canonical string used as the left-hand side of the
/// operator test. Common properties have hand-rolled formatting so
/// `"ByLayer"` / `"7"` / `"0.30mm"` are stable; everything else
/// goes through `geometry_properties()` and pulls the matching
/// row's value out.
pub fn entity_property_value(
&self,
entity: &acadrust::EntityType,
field: &str,
) -> Option<String> {
use crate::entities::traits::EntityTypeOps;
use crate::scene::object::PropValue;
match field {
"layer" => Some(entity.common().layer.clone()),
"color" => Some(Self::format_color(entity.common().color)),
"linetype" => Some(entity.common().linetype.clone()),
"lineweight" => Some(Self::format_lineweight(entity.common().line_weight)),
_ => {
let text_style_names: Vec<String> = self
.document
.text_styles
.iter()
.map(|s| s.name.clone())
.collect();
let section = entity.geometry_properties(&text_style_names)?;
let prop = section.props.into_iter().find(|p| p.field == field)?;
Some(match prop.value {
PropValue::ReadOnly(s) | PropValue::EditText(s) => s,
PropValue::LayerChoice(s) => s,
PropValue::Choice { selected, .. } => selected,
PropValue::ColorChoice(c) => Self::format_color(c),
PropValue::LwChoice(lw) => Self::format_lineweight(lw),
PropValue::LinetypeChoice(s) => s,
PropValue::HatchPatternChoice(s) => s,
PropValue::BoolToggle { value, .. } => value.to_string(),
PropValue::ColorVaries | PropValue::LwVaries => return None,
})
}
}
}
fn format_color(c: acadrust::types::Color) -> String {
use acadrust::types::Color;
match c {
Color::ByLayer => "ByLayer".to_string(),
Color::ByBlock => "ByBlock".to_string(),
Color::Index(i) => i.to_string(),
Color::Rgb { r, g, b } => format!("{},{},{}", r, g, b),
}
}
fn format_lineweight(lw: acadrust::types::LineWeight) -> String {
use acadrust::types::LineWeight;
match lw {
LineWeight::ByLayer => "ByLayer".to_string(),
LineWeight::ByBlock => "ByBlock".to_string(),
LineWeight::Default => "Default".to_string(),
LineWeight::Value(v) => format!("{:.2}mm", v as f64 / 100.0),
}
}
// ── Erase ─────────────────────────────────────────────────────────────