feat(qselect): expand filter workflow

Refs #511
This commit is contained in:
Hakan Seven committed 2026-08-05 13:40:53 +03:00
1 parent e07f4f959b
commit afc94dce13
6 files changed
+722 -163

No files matched your search

+44 -4
View File
@@ -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<String>),
}
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<String>,
pub available_properties: Vec<QSelectPropertyChoice>,
pub candidate_count: usize,
/// `None` = "(Any type)".
pub type_filter: Option<String>,
/// `None` = no property filter; the type filter alone applies.
pub property: Option<QSelectPropertyChoice>,
pub operator: QSelectOp,
pub value: String,
pub mode: QSelectMode,
pub append: bool,
pub error: Option<String>,
}
#[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<String>),
/// 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.
+12
View File
@@ -1313,12 +1313,24 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
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()
+141 -14
View File
@@ -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());
+3 -4
View File
@@ -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,
)
+179 -75
View File
@@ -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<String> = 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<crate::app::QSelectOp> = vec![
let number_property = state.property.as_ref().is_some_and(|property| {
matches!(property.editor, crate::app::QSelectValueEditor::Number)
});
let mut 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,
];
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()
}
});