diff --git a/src/app/mod.rs b/src/app/mod.rs index 93350ef09..a929ffdae 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, - pub layer_filter: Option, + /// `None` = no property filter; the type filter alone applies. + pub property: Option, + 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), - /// Layer filter — `None` means "any layer". - QSelectSetLayer(Option), + /// Property to compare. `None` means "no property filter — just type + /// filter applies"; the operator and value fields are ignored in + /// that case. + QSelectSetProperty(Option), + /// 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. diff --git a/src/app/update.rs b/src/app/update.rs index f2acf748a..6f6fb3c4e 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -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 = None; - let mut layer_filter: Option = 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 diff --git a/src/app/view.rs b/src/app/view.rs index 76a0021e4..d6affd20d 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -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 = vec![QSELECT_ANY.to_string()]; + let mut type_options: Vec = vec![QSELECT_ANY_TYPE.to_string()]; type_options.extend(types.iter().map(|s| (*s).to_string())); - let mut layer_options: Vec = vec![QSELECT_ANY.to_string()]; - layer_options.extend(layers.iter().cloned()); + + let mut prop_options: Vec = + 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 = 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 { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index b79dd1da1..8377c0cab 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -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::(), value.parse::()) + 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 { - let mut names: std::collections::BTreeSet = - 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 = 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 { + 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 = 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 ─────────────────────────────────────────────────────────────