diff --git a/src/app/mod.rs b/src/app/mod.rs index 667bccf35..f04872fe1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -109,6 +109,38 @@ pub enum QSelectOp { Lt, } +/// Candidate set searched by Quick Select. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum QSelectScope { + CurrentSpace, + CurrentSelection, +} + +impl std::fmt::Display for QSelectScope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let source = match self { + QSelectScope::CurrentSpace => "Current space", + QSelectScope::CurrentSelection => "Current selection", + }; + f.write_str(crate::t!(source).as_ref()) + } +} + +/// Whether matching candidates are kept or removed from the result. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum QSelectMode { + Include, + Exclude, +} + +/// Editor used by the Quick Select value field. +#[derive(Clone, Debug)] +pub enum QSelectValueEditor { + Text, + Number, + Choice(Vec), +} + impl std::fmt::Display for QSelectOp { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let s = match self { @@ -131,6 +163,7 @@ impl std::fmt::Display for QSelectOp { pub struct QSelectPropertyChoice { pub field: String, pub label: String, + pub editor: QSelectValueEditor, } impl PartialEq for QSelectPropertyChoice { @@ -148,19 +181,22 @@ impl std::fmt::Display for QSelectPropertyChoice { } /// 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. +/// `(scope, type, property, op, value)` row plus result behavior. #[derive(Clone, Debug)] pub struct QSelectState { + pub scope: QSelectScope, + pub available_types: Vec, + pub available_properties: Vec, + pub candidate_count: usize, /// `None` = "(Any type)". pub type_filter: Option, /// `None` = no property filter; the type filter alone applies. pub property: Option, pub operator: QSelectOp, pub value: String, + pub mode: QSelectMode, pub append: bool, + pub error: Option, } #[derive(Clone, Debug, Default)] @@ -2493,6 +2529,8 @@ pub enum Message { QSelectOpen, /// Close the Quick Select panel without applying. QSelectClose, + /// Candidate scope: active space or the current selection. + QSelectSetScope(QSelectScope), /// Type filter — `None` means "any type". QSelectSetType(Option), /// Property to compare. `None` means "no property filter — just type @@ -2503,6 +2541,8 @@ pub enum Message { QSelectSetOperator(QSelectOp), /// Compare-against value (free-text input). QSelectSetValue(String), + /// Include or exclude objects matching the filter. + QSelectSetMode(QSelectMode), /// Append-to-current-selection toggle. QSelectSetAppend(bool), /// Apply the current filter and close the panel. diff --git a/src/app/update/command.rs b/src/app/update/command.rs index a7d7ed7de..b5a88fa98 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1313,12 +1313,24 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { type_filter = Some(entity_type_name(e).to_string()); } } + let scope = crate::app::QSelectScope::CurrentSpace; + let available_types = self.tabs[i].scene.qselect_entity_type_names(scope); + let available_properties = self.tabs[i] + .scene + .qselect_properties(type_filter.as_deref(), scope); + let candidate_count = self.tabs[i].scene.qselect_candidate_count(scope); self.qselect = Some(crate::app::QSelectState { + scope, + available_types, + available_properties, + candidate_count, type_filter, property: None, operator: crate::app::QSelectOp::Eq, value: String::new(), + mode: crate::app::QSelectMode::Include, append: false, + error: None, }); self.reset_modal_geometry(); Task::none() diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 91c92b249..ceba501ae 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -3845,22 +3845,77 @@ impl OpenCADStudio { Task::none() } - Message::QSelectSetType(t) => { + Message::QSelectSetScope(scope) => { + let i = self.active_tab; + let available_types = self.tabs[i].scene.qselect_entity_type_names(scope); + let type_filter = self.qselect.as_ref().and_then(|state| { + state + .type_filter + .as_ref() + .filter(|selected| available_types.iter().any(|item| item == *selected)) + .cloned() + }); + let available_properties = self.tabs[i] + .scene + .qselect_properties(type_filter.as_deref(), scope); + let candidate_count = self.tabs[i].scene.qselect_candidate_count(scope); 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.scope = scope; + state.available_types = available_types; + state.available_properties = available_properties; + state.candidate_count = candidate_count; + state.type_filter = type_filter; + state.property = state.property.as_ref().and_then(|selected| { + state + .available_properties + .iter() + .find(|available| available.field == selected.field) + .cloned() + }); + state.value.clear(); + state.error = None; + if matches!(scope, crate::app::QSelectScope::CurrentSelection) { + state.append = false; + } + if matches!(state.operator, crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt) + && !state.property.as_ref().is_some_and(|property| { + matches!(property.editor, crate::app::QSelectValueEditor::Number) + }) + { + state.operator = crate::app::QSelectOp::Eq; + } + } + Task::none() + } + + Message::QSelectSetType(t) => { + let i = self.active_tab; + let scope = self + .qselect + .as_ref() + .map_or(crate::app::QSelectScope::CurrentSpace, |state| state.scope); + let properties = self.tabs[i] + .scene + .qselect_properties(t.as_deref(), scope); + if let Some(state) = self.qselect.as_mut() { + let kept_property = state.property.as_ref().and_then(|selected| { + properties + .iter() + .find(|available| available.field == selected.field) + .cloned() }); state.type_filter = t; + state.available_properties = properties; state.property = kept_property; + state.value.clear(); + state.error = None; + if matches!(state.operator, crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt) + && !state.property.as_ref().is_some_and(|property| { + matches!(property.editor, crate::app::QSelectValueEditor::Number) + }) + { + state.operator = crate::app::QSelectOp::Eq; + } } Task::none() } @@ -3868,6 +3923,15 @@ impl OpenCADStudio { Message::QSelectSetProperty(p) => { if let Some(state) = self.qselect.as_mut() { state.property = p; + state.value.clear(); + state.error = None; + if matches!(state.operator, crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt) + && !state.property.as_ref().is_some_and(|property| { + matches!(property.editor, crate::app::QSelectValueEditor::Number) + }) + { + state.operator = crate::app::QSelectOp::Eq; + } } Task::none() } @@ -3875,6 +3939,7 @@ impl OpenCADStudio { Message::QSelectSetOperator(op) => { if let Some(state) = self.qselect.as_mut() { state.operator = op; + state.error = None; } Task::none() } @@ -3882,29 +3947,91 @@ impl OpenCADStudio { Message::QSelectSetValue(v) => { if let Some(state) = self.qselect.as_mut() { state.value = v; + state.error = None; + } + Task::none() + } + + Message::QSelectSetMode(mode) => { + if let Some(state) = self.qselect.as_mut() { + state.mode = mode; + state.error = None; } Task::none() } Message::QSelectSetAppend(b) => { if let Some(state) = self.qselect.as_mut() { - state.append = b; + if matches!(state.scope, crate::app::QSelectScope::CurrentSpace) { + state.append = b; + } + state.error = None; } Task::none() } Message::QSelectApply => { + let validation_error = self.qselect.as_ref().and_then(|state| { + let candidate_count = self.tabs[self.active_tab] + .scene + .qselect_candidate_count(state.scope); + if candidate_count == 0 { + Some(crate::t!("No objects are available in this scope.").into_owned()) + } else if let Some(property) = state.property.as_ref() { + if matches!(state.operator, crate::app::QSelectOp::Any) { + None + } else if matches!( + state.operator, + crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt + ) && !matches!( + property.editor, + crate::app::QSelectValueEditor::Number + ) { + Some( + crate::t!("This operator requires a numeric property.") + .into_owned(), + ) + } else { + match &property.editor { + crate::app::QSelectValueEditor::Number + if crate::entities::common::parse_f64(&state.value).is_none() => + { + Some(crate::t!("Enter a valid number.").into_owned()) + } + crate::app::QSelectValueEditor::Choice(_) + if state.value.is_empty() => + { + Some(crate::t!("Choose a value.").into_owned()) + } + crate::app::QSelectValueEditor::Text + | crate::app::QSelectValueEditor::Number + | crate::app::QSelectValueEditor::Choice(_) => None, + } + } + } else { + None + } + }); + if let Some(error) = validation_error { + if let Some(state) = self.qselect.as_mut() { + state.error = Some(error); + } + return Task::none(); + } let Some(state) = self.qselect.take() else { return Task::none(); }; self.reset_modal_geometry(); let i = self.active_tab; let matched = self.tabs[i].scene.qselect( + state.scope, state.type_filter.as_deref(), state.property.as_ref().map(|p| p.field.as_str()), state.operator, &state.value, - state.append, + state.mode, + state.append + && matches!(state.scope, crate::app::QSelectScope::CurrentSpace), ); self.command_line .push_output(crate::tf!("QSELECT: {} object(s) selected.", matched).as_ref()); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 56989b3b1..d522a6eeb 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1720,12 +1720,11 @@ impl OpenCADStudio { }; let qselect_layer: Element<'_, Message> = if let Some(state) = &self.qselect { - let types = tab.scene.entity_type_names_in_layout(); - let properties = tab.scene.qselect_properties(state.type_filter.as_deref()); qselect_overlay( state, - &types, - &properties, + &state.available_types, + &state.available_properties, + state.candidate_count, self.modal_offset, self.modal_resize, ) diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs index 30a27ea6e..82e6f68b9 100644 --- a/src/app/view/overlay.rs +++ b/src/app/view/overlay.rs @@ -1199,16 +1199,14 @@ pub(super) fn snap_override_overlay(pos: iced::Point) -> Element<'static, Messag const QSELECT_ANY_TYPE: &str = "(Any type)"; const QSELECT_ANY_PROP: &str = "(Any property)"; -/// 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. +/// Quick Select uses the application's shared movable modal frame. Its form +/// groups scope, filter, and result behavior while reusing the same compact +/// field and button styles as the other workspace dialogs. pub(super) fn qselect_overlay<'a>( state: &'a crate::app::QSelectState, types: &[String], - properties: &[(String, String)], + properties: &[crate::app::QSelectPropertyChoice], + candidate_count: usize, modal_offset: iced::Vector, modal_resize: iced::Vector, ) -> Element<'a, Message> { @@ -1216,12 +1214,14 @@ pub(super) fn qselect_overlay<'a>( state, types, properties, + candidate_count, crate::ui::modal::ModalSizing::INTRINSIC, ); let content = qselect_content( state, types, properties, + candidate_count, crate::ui::modal::ModalSizing::FILL, ); let content = crate::ui::modal::intrinsic( @@ -1244,10 +1244,11 @@ pub(super) fn qselect_overlay<'a>( fn qselect_content<'a>( state: &'a crate::app::QSelectState, types: &[String], - properties: &[(String, String)], + properties: &[crate::app::QSelectPropertyChoice], + candidate_count: usize, sizing: crate::ui::modal::ModalSizing, ) -> Element<'a, Message> { - use iced::widget::{checkbox}; + use iced::widget::{checkbox, radio, rule}; let mut type_options: Vec = vec![QSELECT_ANY_TYPE.to_string()]; type_options.extend(types.iter().cloned()); @@ -1255,21 +1256,22 @@ fn qselect_content<'a>( vec![crate::app::QSelectPropertyChoice { field: String::new(), label: QSELECT_ANY_PROP.to_string(), + editor: crate::app::QSelectValueEditor::Text, }]; - prop_options.extend(properties.iter().map(|(field, label)| { - crate::app::QSelectPropertyChoice { - field: field.clone(), - label: label.clone(), - } - })); + prop_options.extend(properties.iter().cloned()); - let op_options: Vec = vec![ + let number_property = state.property.as_ref().is_some_and(|property| { + matches!(property.editor, crate::app::QSelectValueEditor::Number) + }); + let mut op_options: Vec = vec![ crate::app::QSelectOp::Eq, crate::app::QSelectOp::Neq, - crate::app::QSelectOp::Gt, - crate::app::QSelectOp::Lt, - crate::app::QSelectOp::Any, ]; + if number_property { + op_options.push(crate::app::QSelectOp::Gt); + op_options.push(crate::app::QSelectOp::Lt); + } + op_options.push(crate::app::QSelectOp::Any); let type_sel = state .type_filter @@ -1281,11 +1283,9 @@ fn qselect_content<'a>( .unwrap_or(crate::app::QSelectPropertyChoice { field: String::new(), label: QSELECT_ANY_PROP.to_string(), + editor: crate::app::QSelectValueEditor::Text, }); - // 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 field_width = if matches!(sizing.width, iced::Length::Fill) { @@ -1293,49 +1293,121 @@ fn qselect_content<'a>( } else { iced::Length::Shrink }; + let flex_width = if matches!(sizing.width, iced::Length::Fill) { + Fill + } else { + iced::Length::Shrink + }; let label = |s: std::borrow::Cow<'static, str>| { text(s) .size(12) - .width(iced::Length::Fixed(90.0)) + .width(iced::Length::Fixed(112.0)) + }; + let section_label = |s: std::borrow::Cow<'static, str>| { + text(s).size(11).style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.palette().background.base.text.scale_alpha(0.65)), + }) }; - let btn = |lbl: std::borrow::Cow<'static, str>, msg: Message, primary: bool| { - button(text(lbl).size(12)) - .on_press(msg) - .style(move |theme: &Theme, st| { - let palette = theme.palette(); - let pair = match ( - primary, - matches!(st, button::Status::Hovered | button::Status::Pressed), - ) { - (true, true) => palette.primary.strong, - (true, false) => palette.primary.base, - (false, true) => palette.background.strong, - (false, false) => palette.background.weak, - }; - button::Style { - background: Some(Background::Color(pair.color)), - text_color: pair.text, - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 4.0.into(), - }, - ..Default::default() + let value_editor: Element<'a, Message> = match state.property.as_ref() { + Some(property) => match &property.editor { + crate::app::QSelectValueEditor::Choice(options) => { + let selected = (!state.value.is_empty()).then(|| state.value.clone()); + let picker = iced::widget::pick_list( + selected, + options.clone(), + |value| value.to_string(), + ) + .width(field_width); + if value_enabled { + picker.on_select(Message::QSelectSetValue).into() + } else { + picker.into() } - }) - .padding([4, 14]) + } + crate::app::QSelectValueEditor::Text + | crate::app::QSelectValueEditor::Number => { + let mut input = text_input("", &state.value).size(12).width(field_width); + if value_enabled { + input = input.on_input(Message::QSelectSetValue); + } + input.into() + } + }, + None => text_input("", "").size(12).width(field_width).into(), }; - let mut value_input = text_input("", &state.value) - .size(12) - .width(field_width); - if value_enabled { - value_input = value_input.on_input(Message::QSelectSetValue); + let derived_error = if candidate_count == 0 { + Some(crate::t!("No objects are available in this scope.").into_owned()) + } else if value_enabled && number_property + && crate::entities::common::parse_f64(&state.value).is_none() + { + Some(crate::t!("Enter a valid number.").into_owned()) + } else if value_enabled + && state.property.as_ref().is_some_and(|property| { + matches!(property.editor, crate::app::QSelectValueEditor::Choice(_)) + }) + && state.value.is_empty() + { + Some(crate::t!("Choose a value.").into_owned()) + } else if matches!(state.operator, crate::app::QSelectOp::Gt | crate::app::QSelectOp::Lt) + && !number_property + { + Some(crate::t!("This operator requires a numeric property.").into_owned()) + } else { + None + }; + let error = state.error.clone().or(derived_error); + + let scope_options = vec![ + crate::app::QSelectScope::CurrentSpace, + crate::app::QSelectScope::CurrentSelection, + ]; + let scope_picker = iced::widget::pick_list( + Some(state.scope), + scope_options, + |value| value.to_string(), + ) + .on_select(Message::QSelectSetScope) + .width(field_width); + + let mut append = checkbox(state.append).size(14); + if matches!(state.scope, crate::app::QSelectScope::CurrentSpace) { + append = append.on_toggle(Message::QSelectSetAppend); } + let cancel = button(text(t!("Cancel")).size(12)) + .on_press(Message::QSelectClose) + .style(button::subtle) + .padding([5, 16]); + let apply = button(text(t!("Apply")).size(12)) + .style(button::primary) + .padding([5, 18]); + let apply = if error.is_none() { + apply.on_press(Message::QSelectApply) + } else { + apply + }; + let panel_body = column![ + section_label(t!("Scope")), + Space::new().height(5), + row![label(t!("Apply to:")), scope_picker] + .align_y(iced::Alignment::Center) + .spacing(8) + .width(sizing.width), + Space::new().height(4), + text(format!("{} candidate object(s)", candidate_count)) + .size(11) + .style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.palette().background.base.text.scale_alpha(0.65)), + }), + Space::new().height(10), + container(rule::horizontal(1)).width(flex_width), + Space::new().height(10), + section_label(t!("Filter")), + Space::new().height(5), row![ label(t!("Object type:")), iced::widget::pick_list( @@ -1364,12 +1436,12 @@ fn qselect_content<'a>( |value| value.to_string(), ) .on_select(|p: crate::app::QSelectPropertyChoice| { - if p.field.is_empty() { - Message::QSelectSetProperty(None) - } else { - Message::QSelectSetProperty(Some(p)) - } - }) + if p.field.is_empty() { + Message::QSelectSetProperty(None) + } else { + Message::QSelectSetProperty(Some(p)) + } + }) .width(field_width), ] .align_y(iced::Alignment::Center) @@ -1390,25 +1462,57 @@ fn qselect_content<'a>( .spacing(8) .width(sizing.width), Space::new().height(6), - row![label(t!("Value:")), value_input,] + row![label(t!("Value:")), value_editor] .align_y(iced::Alignment::Center) .spacing(8) .width(sizing.width), Space::new().height(10), + container(rule::horizontal(1)).width(flex_width), + Space::new().height(10), + section_label(t!("Result")), + Space::new().height(5), + column![ + radio( + t!("Include matching objects"), + crate::app::QSelectMode::Include, + Some(state.mode), + Message::QSelectSetMode, + ) + .size(14) + .text_size(12), + radio( + t!("Exclude matching objects"), + crate::app::QSelectMode::Exclude, + Some(state.mode), + Message::QSelectSetMode, + ) + .size(14) + .text_size(12), + ] + .spacing(5), + Space::new().height(8), row![ - checkbox(state.append) - .on_toggle(Message::QSelectSetAppend) - .size(14), + append, Space::new().width(6), - text(t!("Append to current selection")).size(12), + text(t!("Append result to current selection")).size(12), ] .align_y(iced::Alignment::Center), - Space::new().height(14), + Space::new().height(10), + if let Some(message) = error { + text(message) + .size(11) + .style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.palette().danger.base.color), + }) + } else { + text("").size(11) + }, + Space::new().height(8), row![ - Space::new().width(field_width), - btn(t!("Cancel"), Message::QSelectClose, false), + Space::new().width(flex_width), + cancel, Space::new().width(8), - btn(t!("Apply"), Message::QSelectApply, true), + apply, ] .align_y(iced::Alignment::Center), ] @@ -1423,13 +1527,13 @@ fn qselect_content<'a>( .style(|theme: &Theme| { let palette = theme.palette(); container::Style { - background: Some(Background::Color(palette.background.weak.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 6.0.into(), - }, - ..Default::default() + background: Some(Background::Color(palette.background.weak.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() } }); diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 482852fe2..ced46be3b 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -56,6 +56,30 @@ impl Scene { .unwrap_or_default() } + fn qselect_candidate_handles(&self, scope: crate::app::QSelectScope) -> Vec { + match scope { + crate::app::QSelectScope::CurrentSpace => self.current_layout_entity_handles(), + crate::app::QSelectScope::CurrentSelection => { + self.selected.iter().copied().collect() + } + } + } + + pub fn qselect_candidate_count(&self, scope: crate::app::QSelectScope) -> usize { + self.qselect_candidate_handles(scope).len() + } + + pub fn qselect_entity_type_names(&self, scope: crate::app::QSelectScope) -> Vec { + use crate::entities::traits::entity_type_name; + let mut names = std::collections::BTreeSet::new(); + for h in self.qselect_candidate_handles(scope) { + if let Some(entity) = self.document.get_entity(h) { + names.insert(entity_type_name(entity).to_string()); + } + } + names.into_iter().collect() + } + /// Extends the current selection with every entity in the active /// layout that matches one of the selected entities by `(variant, /// layer)`. The seed selection stays selected. No-op when nothing is @@ -112,9 +136,9 @@ impl Scene { self.selected.len() } - /// 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. + /// Builds a selection from the chosen scope and filter. Exclude mode + /// keeps candidates that do not match. Append mode unions the result + /// with the selection that existed before the query. /// /// `type_name` of `None` means "any type". `property_field` of /// `None` skips the property test (only the type filter applies). @@ -123,19 +147,19 @@ impl Scene { /// reject anything non-numeric. pub fn qselect( &mut self, + scope: crate::app::QSelectScope, type_name: Option<&str>, property_field: Option<&str>, op: crate::app::QSelectOp, value: &str, + mode: crate::app::QSelectMode, append: bool, ) -> usize { - use crate::app::QSelectOp; + use crate::app::{QSelectMode, QSelectOp}; use crate::entities::traits::entity_type_name; - if !append { - self.selected.clear(); - } - let handles = self.current_layout_entity_handles(); - let mut matched = 0; + let previous = self.selected.clone(); + let handles = self.qselect_candidate_handles(scope); + let mut result = HashSet::default(); for h in handles { let Some(e) = self.document.get_entity(h) else { continue; @@ -150,43 +174,48 @@ impl Scene { { continue; } - 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 - } + let type_ok = type_name.is_none_or(|t| entity_type_name(e) == t); + let prop_ok = if !type_ok { + true + } else { + match (property_field, op) { + (None, _) | (_, QSelectOp::Any) => true, + (Some(field), op) => { + match self.entity_property_value(e, field) { + Some(actual) => match op { + QSelectOp::Eq => actual.eq_ignore_ascii_case(value), + QSelectOp::Neq => !actual.eq_ignore_ascii_case(value), + QSelectOp::Gt | QSelectOp::Lt => { + match ( + crate::entities::common::parse_f64(&actual), + crate::entities::common::parse_f64(value), + ) { + (Some(a), Some(b)) if matches!(op, QSelectOp::Gt) => a > b, + (Some(a), Some(b)) => a < b, + _ => false, + } + } + QSelectOp::Any => true, + }, + None => false, } - QSelectOp::Any => true, } } }; - if prop_ok { - self.selected.insert(h); - matched += 1; + let matches_filter = type_ok && prop_ok; + let keep = match mode { + QSelectMode::Include => matches_filter, + QSelectMode::Exclude => !matches_filter, + }; + if keep { + result.insert(h); } } - self.bump_selection(); - matched + if append { + result.extend(previous); + } + self.replace_selection(result); + self.selected.len() } /// Returns the sorted set of entity-type names present in the active @@ -223,25 +252,100 @@ impl Scene { !self.selection_filter.is_empty() } - /// 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. + /// Returns common and type-specific filter properties with the editor + /// each value needs. Choice editors are enriched from both document + /// tables and values present in the selected candidate scope. pub fn qselect_properties( &self, type_name: Option<&str>, - ) -> Vec<(String, String)> { + scope: crate::app::QSelectScope, + ) -> Vec { 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()), + use crate::scene::model::object::PropValue; + use crate::app::{QSelectPropertyChoice, QSelectValueEditor}; + + let candidate_handles: Vec = self + .qselect_candidate_handles(scope) + .into_iter() + .filter(|h| { + self.document.get_entity(*h).is_some_and(|entity| { + type_name.is_none_or(|t| entity_type_name(entity) == t) + }) + }) + .collect(); + + let mut layer_options: Vec = self + .document + .layers + .iter() + .map(|layer| layer.name.clone()) + .collect(); + layer_options.sort_by_key(|value| value.to_lowercase()); + let mut linetype_options = vec!["ByLayer".to_string(), "ByBlock".to_string()]; + linetype_options.extend( + self.document + .line_types + .iter() + .map(|linetype| linetype.name.clone()), + ); + + let choice = |field: &str, label: String, editor: QSelectValueEditor| { + QSelectPropertyChoice { + field: field.to_string(), + label, + editor, + } + }; + let mut out = vec![ + choice("handle", crate::t!("Handle").into_owned(), QSelectValueEditor::Text), + choice( + "color", + crate::t!("Color").into_owned(), + QSelectValueEditor::Choice(vec!["ByLayer".into(), "ByBlock".into()]), + ), + choice( + "layer", + crate::t!("Layer").into_owned(), + QSelectValueEditor::Choice(layer_options.clone()), + ), + choice( + "linetype", + crate::t!("Linetype").into_owned(), + QSelectValueEditor::Choice(linetype_options.clone()), + ), + choice( + "linetype_scale", + crate::t!("Linetype scale").into_owned(), + QSelectValueEditor::Number, + ), + choice( + "plot_style", + crate::t!("Plot style").into_owned(), + QSelectValueEditor::Choice(vec![ + "ByLayer".into(), + "ByBlock".into(), + "ByColor".into(), + ]), + ), + choice( + "lineweight", + crate::t!("Lineweight").into_owned(), + QSelectValueEditor::Choice(vec![ + "ByLayer".into(), + "ByBlock".into(), + "Default".into(), + ]), + ), + choice( + "transparency", + crate::t!("Transparency").into_owned(), + QSelectValueEditor::Choice(vec!["ByLayer".into()]), + ), + choice( + "hyperlink", + crate::t!("Hyperlink").into_owned(), + QSelectValueEditor::Text, + ), ]; if let Some(t) = type_name { let text_style_names: Vec = self @@ -250,22 +354,146 @@ impl Scene { .iter() .map(|s| s.name.clone()) .collect(); - let sample = self - .current_layout_entity_handles() - .into_iter() + let sample = candidate_handles + .iter() + .copied() .filter_map(|h| self.document.get_entity(h)) .find(|e| entity_type_name(e) == t); if let Some(sample) = sample { - for section in sample.geometry_properties(&text_style_names) { + let mut sections = vec![crate::scene::cache::properties::general_section(sample)]; + if let Some(section) = + crate::scene::cache::properties::visualization_section(sample) + { + sections.push(section); + } + sections.extend(sample.geometry_properties(&text_style_names)); + for section in sections { 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())); + if out.iter().any(|item| item.field == prop.field) { + continue; + } + let editor = if prop.field == "material" { + QSelectValueEditor::Choice(vec![ + "ByLayer".into(), + "ByBlock".into(), + "Custom".into(), + ]) + } else { + match prop.value { + PropValue::ReadOnly(ref value) + | PropValue::EditText(ref value) => { + let field = prop.field.to_ascii_lowercase(); + let textual = [ + "name", + "text", + "content", + "style", + "tag", + "prompt", + "value", + "description", + "format", + "font", + "path", + "file", + "url", + ] + .iter() + .any(|part| field.contains(part)); + if !textual + && crate::entities::common::parse_f64(value).is_some() + { + QSelectValueEditor::Number + } else { + QSelectValueEditor::Text + } + } + PropValue::LayerChoice(_) => { + QSelectValueEditor::Choice(layer_options.clone()) + } + PropValue::Choice { ref options, .. } => { + QSelectValueEditor::Choice(options.clone()) + } + PropValue::EditChoice { + ref value, + ref options, + } => { + let mut values = options.clone(); + if !values.iter().any(|item| item == value) { + values.push(value.clone()); + } + QSelectValueEditor::Choice(values) + } + PropValue::ColorChoice(_) => QSelectValueEditor::Choice(vec![ + "ByLayer".into(), + "ByBlock".into(), + ]), + PropValue::LwChoice(_) => QSelectValueEditor::Choice(vec![ + "ByLayer".into(), + "ByBlock".into(), + "Default".into(), + ]), + PropValue::LinetypeChoice(_) => { + QSelectValueEditor::Choice(linetype_options.clone()) + } + PropValue::HatchPatternChoice(value) => { + let mut patterns: Vec = + crate::scene::model::hatch_patterns::catalog() + .iter() + .map(|entry| entry.name.clone()) + .collect(); + if !patterns.iter().any(|item| item == &value) { + patterns.push(value); + } + QSelectValueEditor::Choice(patterns) + } + PropValue::BoolToggle { .. } => { + QSelectValueEditor::Choice(vec![ + "false".into(), + "true".into(), + ]) + } + PropValue::AttrText { .. } => QSelectValueEditor::Text, + PropValue::Stepper { .. } + | PropValue::ColorVaries + | PropValue::LwVaries => continue, + } + }; + out.push(choice(prop.field, prop.label, editor)); } } } } + + for property in &mut out { + let QSelectValueEditor::Choice(options) = &mut property.editor else { + continue; + }; + let common_choice = matches!( + property.field.as_str(), + "color" + | "layer" + | "linetype" + | "plot_style" + | "lineweight" + | "transparency" + | "material" + ); + if !common_choice && !options.is_empty() { + continue; + } + for handle in &candidate_handles { + let Some(entity) = self.document.get_entity(*handle) else { + continue; + }; + if let Some(value) = self.entity_property_value(entity, &property.field) { + if !options.iter().any(|option| option.eq_ignore_ascii_case(&value)) { + options.push(value); + } + } + } + options.sort_by_key(|value| value.to_lowercase()); + } out } @@ -283,10 +511,53 @@ impl Scene { use crate::entities::traits::EntityTypeOps; use crate::scene::model::object::PropValue; match field { + "handle" => Some(entity.common().handle.value().to_string()), "layer" => Some(entity.common().layer.clone()), "color" => Some(Self::format_color(entity.common().color)), - "linetype" => Some(entity.common().linetype.clone()), + "linetype" => Some(if entity.common().linetype.is_empty() { + "ByLayer".to_string() + } else { + entity.common().linetype.clone() + }), + "linetype_scale" => Some(format!("{:.4}", entity.common().linetype_scale)), + "plot_style" => Some( + match entity.common().plotstyle_flags { + 0 => "ByLayer", + 1 => "ByBlock", + _ => "ByColor", + } + .to_string(), + ), "lineweight" => Some(Self::format_lineweight(entity.common().line_weight)), + "transparency" => Some(if entity.common().transparency.alpha() == 0 { + "ByLayer".to_string() + } else { + ((entity.common().transparency.alpha() as f64 / 255.0 * 100.0).round() as u32) + .to_string() + }), + "hyperlink" => Some( + entity + .common() + .extended_data + .get_record("PE_URL") + .and_then(|record| { + record.values.iter().find_map(|value| match value { + acadrust::xdata::XDataValue::String(text) if !text.is_empty() => { + Some(text.clone()) + } + _ => None, + }) + }) + .unwrap_or_default(), + ), + "material" => Some( + match entity.common().material_flags { + 0 => "ByLayer", + 1 => "ByBlock", + _ => "Custom", + } + .to_string(), + ), _ => { let text_style_names: Vec = self .document @@ -294,8 +565,14 @@ impl Scene { .iter() .map(|s| s.name.clone()) .collect(); - let prop = entity - .geometry_properties(&text_style_names) + let mut sections = vec![crate::scene::cache::properties::general_section(entity)]; + if let Some(section) = + crate::scene::cache::properties::visualization_section(entity) + { + sections.push(section); + } + sections.extend(entity.geometry_properties(&text_style_names)); + let prop = sections .into_iter() .flat_map(|s| s.props) .find(|p| p.field == field)?;