diff --git a/src/app/mod.rs b/src/app/mod.rs index e15238efc..0e2242b9e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -223,10 +223,42 @@ impl std::fmt::Display for QSelectPropertyChoice { } } -/// Open Quick Select panel state. The filter is one -/// `(scope, type, property, op, value)` row plus result behavior. +/// One committed condition in the Quick Select filter list. +#[derive(Clone, Debug)] +pub struct QSelectCondition { + pub type_filter: Option, + pub property: Option, + pub operator: QSelectOp, + pub value: String, +} + +impl QSelectCondition { + pub fn validation_error(&self) -> Option { + if self.type_filter.is_none() && self.property.is_none() { + return Some(crate::t!("Choose an object type or property.").into_owned()); + } + let Some(property) = &self.property else { return None; }; + if matches!(self.operator, QSelectOp::Any) { return None; } + if matches!(self.operator, QSelectOp::Gt | QSelectOp::Lt) + && !matches!(property.editor, QSelectValueEditor::Number) { + return Some(crate::t!("This operator requires a numeric property.").into_owned()); + } + match &property.editor { + QSelectValueEditor::Number if crate::entities::common::parse_f64(&self.value) + .is_none_or(|value| !value.is_finite()) => + Some(crate::t!("Enter a valid number.").into_owned()), + QSelectValueEditor::Choice(_) if self.value.is_empty() => + Some(crate::t!("Choose a value.").into_owned()), + _ => None, + } + } +} + +/// Quick Select editor and committed conditions (combined with AND). #[derive(Clone, Debug)] pub struct QSelectState { + pub conditions: Vec, + pub editing_condition: Option, pub scope: QSelectScope, pub available_types: Vec, pub available_properties: Vec, @@ -242,8 +274,30 @@ pub struct QSelectState { pub error: Option, } +impl QSelectState { + pub fn normalize_operator(&mut self) { + if matches!(self.operator, QSelectOp::Gt | QSelectOp::Lt) + && !self.property.as_ref().is_some_and(|property| { + matches!(property.editor, QSelectValueEditor::Number) + }) + { + self.operator = QSelectOp::Eq; + } + } + + pub fn draft_condition(&self) -> QSelectCondition { + QSelectCondition { + type_filter: self.type_filter.clone(), + property: self.property.clone(), + operator: self.operator, + value: self.value.clone(), + } + } +} + #[derive(Clone, Debug)] struct QSelectSettings { + conditions: Vec, scope: QSelectScope, type_filter: Option, property_field: Option, @@ -256,6 +310,7 @@ struct QSelectSettings { impl From<&QSelectState> for QSelectSettings { fn from(state: &QSelectState) -> Self { Self { + conditions: state.conditions.clone(), scope: state.scope, type_filter: state.type_filter.clone(), property_field: state @@ -3891,6 +3946,13 @@ pub enum Message { /// Append-to-current-selection toggle. QSelectSetAppend(bool), /// Apply the current filter and close the panel. + QSelectAddCondition, + /// Hide the filter and sample an object's filterable properties. + QSelectPickObject, + QSelectEditCondition(usize), + QSelectRemoveCondition(usize), + QSelectClearConditions, + QSelectCancelEdit, QSelectApply, /// The user clicked the title-bar ✕ (fires before the window closes). WindowCloseRequested(window::Id), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index f9593c4cf..50c26e176 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -106,6 +106,7 @@ pub(in crate::app) use file::background_task; #[cfg(not(target_arch = "wasm32"))] pub(in crate::app) use file::native_paths_match; mod page_setup_import; +mod qselect; mod style; pub(in crate::app) mod util; mod viewport; @@ -7011,14 +7012,7 @@ impl OpenCADStudio { 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; - } + state.normalize_operator(); } Task::none() } @@ -7042,14 +7036,7 @@ impl OpenCADStudio { 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; - } + state.normalize_operator(); } Task::none() } @@ -7059,14 +7046,7 @@ impl OpenCADStudio { 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; - } + state.normalize_operator(); } Task::none() } @@ -7105,47 +7085,77 @@ impl OpenCADStudio { Task::none() } + Message::QSelectPickObject => self.on_qselect_pick_object(), + Message::QSelectAddCondition => { + if let Some(state) = self.qselect.as_mut() { + let condition = state.draft_condition(); + if let Some(error) = condition.validation_error() { + state.error = Some(error); + return Task::none(); + } + if let Some(index) = state.editing_condition.take() { + if let Some(existing) = state.conditions.get_mut(index) { *existing = condition; } + } else { state.conditions.push(condition); } + state.error = None; + } + Task::none() + } + Message::QSelectEditCondition(index) => { + let condition = self.qselect.as_ref().and_then(|state| state.conditions.get(index).cloned()); + if let Some(condition) = condition { + let scope = self.qselect.as_ref().unwrap().scope; + let properties = self.tabs[self.active_tab].scene.qselect_properties(condition.type_filter.as_deref(), scope); + if let Some(state) = self.qselect.as_mut() { + state.available_properties = properties; + state.type_filter = condition.type_filter; + state.property = condition.property; + state.operator = condition.operator; + state.value = condition.value; + state.editing_condition = Some(index); + state.error = None; + } + } + Task::none() + } + Message::QSelectRemoveCondition(index) => { + if let Some(state) = self.qselect.as_mut() { + if index < state.conditions.len() { + state.conditions.remove(index); + state.editing_condition = match state.editing_condition { + Some(editing) if editing == index => None, + Some(editing) if editing > index => Some(editing - 1), + other => other, + }; + } + state.error = None; + } + Task::none() + } + Message::QSelectClearConditions => { + if let Some(state) = self.qselect.as_mut() { + state.conditions.clear(); + state.editing_condition = None; + state.error = None; + } + Task::none() + } + Message::QSelectCancelEdit => { + if let Some(state) = self.qselect.as_mut() { + state.editing_condition = None; + 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 { + if self.tabs[self.active_tab].scene.qselect_candidate_count(state.scope) == 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 if state.editing_condition.is_some() { + Some(crate::t!("Update or cancel the condition edit before applying.").into_owned()) + } else if state.conditions.is_empty() { + state.draft_condition().validation_error() } else { - None + state.conditions.iter().find_map(|condition| condition.validation_error()) } }); if let Some(error) = validation_error { @@ -7160,13 +7170,11 @@ impl OpenCADStudio { self.qselect_settings = Some((&state).into()); 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.mode, + let conditions = if state.conditions.is_empty() { + vec![state.draft_condition()] + } else { state.conditions.clone() }; + let matched = self.tabs[i].scene.qselect_conditions( + state.scope, &conditions, state.mode, state.append && matches!(state.scope, crate::app::QSelectScope::CurrentSpace), ); self.command_line @@ -7190,6 +7198,11 @@ impl OpenCADStudio { .collect(); wblock.error_message = None; self.active_modal = Some(super::ModalKind::WriteBlock); + } else { + // FILTER may be opened from a still-focused command input. + // Release text focus so Delete and clipboard shortcuts act + // on the resulting pick-first selection, not on that input. + return self.unfocus_widgets(); } Task::none() }