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()
}
});
+343 -66
View File
@@ -56,6 +56,30 @@ impl Scene {
.unwrap_or_default()
}
fn qselect_candidate_handles(&self, scope: crate::app::QSelectScope) -> Vec<Handle> {
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<String> {
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::<f64>(), value.parse::<f64>())
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<crate::app::QSelectPropertyChoice> {
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<Handle> = 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<String> = 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<String> = 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<String> =
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<String> = 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)?;