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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-05-29 21:09:56 +03:00
commit 7d9ac435cb
4 changed files with 326 additions and 60 deletions

View file

@ -56,13 +56,70 @@ pub struct GripPendingValue {
pub label: &'static str, pub label: &'static str,
} }
/// Open Quick Select panel state. `type_filter` / `layer_filter` of /// Operator the Quick Select filter applies between an entity's
/// `None` match every entity in that field; otherwise the filter is the /// property value and the user-typed test value.
/// literal name to compare against. #[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)] #[derive(Clone, Debug)]
pub struct QSelectState { pub struct QSelectState {
/// `None` = "(Any type)".
pub type_filter: Option<String>, 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, pub append: bool,
} }
use crate::snap::Snapper; use crate::snap::Snapper;
@ -652,8 +709,14 @@ pub enum Message {
QSelectClose, QSelectClose,
/// Type filter — `None` means "any type". /// Type filter — `None` means "any type".
QSelectSetType(Option<String>), QSelectSetType(Option<String>),
/// Layer filter — `None` means "any layer". /// Property to compare. `None` means "no property filter — just type
QSelectSetLayer(Option<String>), /// 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. /// Append-to-current-selection toggle.
QSelectSetAppend(bool), QSelectSetAppend(bool),
/// Apply the current filter and close the panel. /// Apply the current filter and close the panel.

View file

@ -3392,22 +3392,23 @@ impl OpenCADStudio {
Message::QSelectOpen => { Message::QSelectOpen => {
let i = self.active_tab; let i = self.active_tab;
self.tabs[i].scene.selection.borrow_mut().context_menu = None; self.tabs[i].scene.selection.borrow_mut().context_menu = None;
// Seed filters from the first selected entity (if any) so // Seed the type filter from the first selected entity so a
// a right-click → Quick Select on a known object pre-fills // right-click → Quick Select on a known object opens the
// the panel — same idea as Select Similar but exposes the // panel pre-tuned to that entity's type. Property defaults
// pairing for editing. // to "(Any property)" so the user immediately picks what
// they want to compare.
let mut type_filter: Option<String> = None; 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(&h) = self.tabs[i].scene.selected.iter().next() {
if let Some(e) = self.tabs[i].scene.document.get_entity(h) { if let Some(e) = self.tabs[i].scene.document.get_entity(h) {
use crate::entities::traits::entity_type_name; use crate::entities::traits::entity_type_name;
type_filter = Some(entity_type_name(e).to_string()); type_filter = Some(entity_type_name(e).to_string());
layer_filter = Some(e.as_entity().layer().to_string());
} }
} }
self.qselect = Some(crate::app::QSelectState { self.qselect = Some(crate::app::QSelectState {
type_filter, type_filter,
layer_filter, property: None,
operator: crate::app::QSelectOp::Eq,
value: String::new(),
append: false, append: false,
}); });
Task::none() Task::none()
@ -3420,14 +3421,41 @@ impl OpenCADStudio {
Message::QSelectSetType(t) => { Message::QSelectSetType(t) => {
if let Some(state) = self.qselect.as_mut() { 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.type_filter = t;
state.property = kept_property;
} }
Task::none() Task::none()
} }
Message::QSelectSetLayer(l) => { Message::QSelectSetProperty(p) => {
if let Some(state) = self.qselect.as_mut() { 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() Task::none()
} }
@ -3446,7 +3474,9 @@ impl OpenCADStudio {
let i = self.active_tab; let i = self.active_tab;
let matched = self.tabs[i].scene.qselect( let matched = self.tabs[i].scene.qselect(
state.type_filter.as_deref(), 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, state.append,
); );
self.command_line self.command_line

View file

@ -912,8 +912,10 @@ impl OpenCADStudio {
let qselect_layer: Element<'_, Message> = if let Some(state) = &self.qselect { let qselect_layer: Element<'_, Message> = if let Some(state) = &self.qselect {
let types = tab.scene.entity_type_names_in_layout(); let types = tab.scene.entity_type_names_in_layout();
let layers = tab.scene.entity_layers_in_layout(); let properties = tab
qselect_overlay(state, &types, &layers) .scene
.qselect_properties(state.type_filter.as_deref());
qselect_overlay(state, &types, &properties)
} else { } else {
iced::widget::Space::new().width(0).height(0).into() 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 ───────────────────────────────────────────────────── // ── 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 /// Floating panel for the Quick Select feature. Single-row filter:
/// type, layer) filter the candidate set down to entities matching both; /// object type → property → operator → value, plus an "Append to current
/// "(Any)" in either slot skips that filter. The panel returns to the /// selection" checkbox. The property dropdown is type-aware — Common
/// stored state on every render, so editing one filter doesn't reset the /// properties (Layer, Color, Linetype, Lineweight) are always shown;
/// other. Outside-click and Cancel both dismiss without applying. /// 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>( fn qselect_overlay<'a>(
state: &'a crate::app::QSelectState, state: &'a crate::app::QSelectState,
types: &[&'static str], types: &[&'static str],
layers: &[String], properties: &[(String, String)],
) -> Element<'a, Message> { ) -> Element<'a, Message> {
use iced::widget::{checkbox, pick_list}; use iced::widget::{checkbox, pick_list};
const BG: Color = Color { r: 0.12, g: 0.12, b: 0.12, a: 0.98 }; 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_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 }; 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())); 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 let type_sel = state
.type_filter .type_filter
.clone() .clone()
.unwrap_or_else(|| QSELECT_ANY.to_string()); .unwrap_or_else(|| QSELECT_ANY_TYPE.to_string());
let layer_sel = state let prop_sel = state.property.clone().unwrap_or(crate::app::QSelectPropertyChoice {
.layer_filter field: String::new(),
.clone() label: QSELECT_ANY_PROP.to_string(),
.unwrap_or_else(|| QSELECT_ANY.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)); 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]) .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![ let panel_body = column![
text("Quick Select").size(14).color(TEXT), text("Quick Select").size(14).color(TEXT),
Space::new().height(10), Space::new().height(10),
row![ row![
label("Object type:"), label("Object type:"),
pick_list(type_options, Some(type_sel), |s: String| { pick_list(type_options, Some(type_sel), |s: String| {
if s == QSELECT_ANY { if s == QSELECT_ANY_TYPE {
Message::QSelectSetType(None) Message::QSelectSetType(None)
} else { } else {
Message::QSelectSetType(Some(s)) Message::QSelectSetType(Some(s))
@ -1593,18 +1626,30 @@ fn qselect_overlay<'a>(
.spacing(8), .spacing(8),
Space::new().height(6), Space::new().height(6),
row![ row![
label("Layer:"), label("Property:"),
pick_list(layer_options, Some(layer_sel), |s: String| { pick_list(prop_options, Some(prop_sel), |p: crate::app::QSelectPropertyChoice| {
if s == QSELECT_ANY { if p.field.is_empty() {
Message::QSelectSetLayer(None) Message::QSelectSetProperty(None)
} else { } else {
Message::QSelectSetLayer(Some(s)) Message::QSelectSetProperty(Some(p))
} }
}) })
.width(Fill), .width(Fill),
] ]
.align_y(iced::Alignment::Center) .align_y(iced::Alignment::Center)
.spacing(8), .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), Space::new().height(10),
row![ row![
checkbox(state.append) checkbox(state.append)
@ -1627,7 +1672,7 @@ fn qselect_overlay<'a>(
let panel = container(panel_body) let panel = container(panel_body)
.padding(16) .padding(16)
.width(iced::Length::Fixed(360.0)) .width(iced::Length::Fixed(400.0))
.style(|_: &Theme| container::Style { .style(|_: &Theme| container::Style {
background: Some(Background::Color(BG)), background: Some(Background::Color(BG)),
border: Border { border: Border {

View file

@ -3763,18 +3763,24 @@ impl Scene {
added added
} }
/// Replaces (or extends, when `append` is true) the current selection /// Replaces (or extends, when `append` is true) the current
/// with every entity in the active layout that matches the optional /// selection with every entity in the active layout that matches
/// `type_name` and `layer` filters. A `None` filter means "any". /// the filter. Returns the number of newly-matching entities.
/// Returns the number of matching entities (i.e. the post-call ///
/// selection size if `append` was false, or the new total if it was /// `type_name` of `None` means "any type". `property_field` of
/// true and previously-selected matches are not double-counted). /// `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( pub fn qselect(
&mut self, &mut self,
type_name: Option<&str>, type_name: Option<&str>,
layer: Option<&str>, property_field: Option<&str>,
op: crate::app::QSelectOp,
value: &str,
append: bool, append: bool,
) -> usize { ) -> usize {
use crate::app::QSelectOp;
use crate::entities::traits::entity_type_name; use crate::entities::traits::entity_type_name;
if !append { if !append {
self.selected.clear(); self.selected.clear();
@ -3785,9 +3791,37 @@ impl Scene {
let Some(e) = self.document.get_entity(h) else { let Some(e) = self.document.get_entity(h) else {
continue; continue;
}; };
let type_ok = type_name.is_none_or(|t| entity_type_name(e) == t); if let Some(t) = type_name {
let layer_ok = layer.is_none_or(|l| e.as_entity().layer() == l); if entity_type_name(e) != t {
if type_ok && layer_ok { 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); self.selected.insert(h);
matched += 1; matched += 1;
} }
@ -3811,18 +3845,112 @@ impl Scene {
names.into_iter().collect() names.into_iter().collect()
} }
/// Returns the sorted set of layer names referenced by entities in /// Returns the list of `(field, label)` pairs the Quick Select
/// the active layout. Used to populate the Quick Select "Layer" /// "Properties" dropdown should show given the current type filter:
/// dropdown. ///
pub fn entity_layers_in_layout(&self) -> Vec<String> { /// * Common properties (Layer, Color, Linetype, Lineweight) are
let mut names: std::collections::BTreeSet<String> = /// always included.
std::collections::BTreeSet::new(); /// * When `type_name` names a specific entity type present in the
for h in self.current_layout_entity_handles() { /// active layout, the first entity of that type contributes its
if let Some(e) = self.document.get_entity(h) { /// `geometry_properties()` rows (Start X, Length, Radius, …) so
names.insert(e.as_entity().layer().to_string()); /// 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 ───────────────────────────────────────────────────────────── // ── Erase ─────────────────────────────────────────────────────────────