feat(qselect): expand filter workflow

Refs #511
This commit is contained in:
Hakan Seven 2026-08-05 13:40:53 +03:00
commit afc94dce13
6 changed files with 721 additions and 162 deletions

View file

@ -109,6 +109,38 @@ pub enum QSelectOp {
Lt, 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 { impl std::fmt::Display for QSelectOp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self { let s = match self {
@ -131,6 +163,7 @@ impl std::fmt::Display for QSelectOp {
pub struct QSelectPropertyChoice { pub struct QSelectPropertyChoice {
pub field: String, pub field: String,
pub label: String, pub label: String,
pub editor: QSelectValueEditor,
} }
impl PartialEq for QSelectPropertyChoice { impl PartialEq for QSelectPropertyChoice {
@ -148,19 +181,22 @@ impl std::fmt::Display for QSelectPropertyChoice {
} }
/// Open Quick Select panel state. The filter is one /// Open Quick Select panel state. The filter is one
/// `(type, property, op, value)` row plus an Append-to-current-selection /// `(scope, type, property, op, value)` row plus result behavior.
/// toggle, mirroring the classic QSELECT dialog: the panel filters
/// candidate entities (entire layout) by type, then by the chosen
/// property compared to the typed value using the operator.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct QSelectState { pub struct QSelectState {
pub scope: QSelectScope,
pub available_types: Vec<String>,
pub available_properties: Vec<QSelectPropertyChoice>,
pub candidate_count: usize,
/// `None` = "(Any type)". /// `None` = "(Any type)".
pub type_filter: Option<String>, pub type_filter: Option<String>,
/// `None` = no property filter; the type filter alone applies. /// `None` = no property filter; the type filter alone applies.
pub property: Option<QSelectPropertyChoice>, pub property: Option<QSelectPropertyChoice>,
pub operator: QSelectOp, pub operator: QSelectOp,
pub value: String, pub value: String,
pub mode: QSelectMode,
pub append: bool, pub append: bool,
pub error: Option<String>,
} }
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
@ -2493,6 +2529,8 @@ pub enum Message {
QSelectOpen, QSelectOpen,
/// Close the Quick Select panel without applying. /// Close the Quick Select panel without applying.
QSelectClose, QSelectClose,
/// Candidate scope: active space or the current selection.
QSelectSetScope(QSelectScope),
/// Type filter — `None` means "any type". /// Type filter — `None` means "any type".
QSelectSetType(Option<String>), QSelectSetType(Option<String>),
/// Property to compare. `None` means "no property filter — just type /// Property to compare. `None` means "no property filter — just type
@ -2503,6 +2541,8 @@ pub enum Message {
QSelectSetOperator(QSelectOp), QSelectSetOperator(QSelectOp),
/// Compare-against value (free-text input). /// Compare-against value (free-text input).
QSelectSetValue(String), QSelectSetValue(String),
/// Include or exclude objects matching the filter.
QSelectSetMode(QSelectMode),
/// Append-to-current-selection toggle. /// Append-to-current-selection toggle.
QSelectSetAppend(bool), QSelectSetAppend(bool),
/// Apply the current filter and close the panel. /// Apply the current filter and close the panel.

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()); 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 { self.qselect = Some(crate::app::QSelectState {
scope,
available_types,
available_properties,
candidate_count,
type_filter, type_filter,
property: None, property: None,
operator: crate::app::QSelectOp::Eq, operator: crate::app::QSelectOp::Eq,
value: String::new(), value: String::new(),
mode: crate::app::QSelectMode::Include,
append: false, append: false,
error: None,
}); });
self.reset_modal_geometry(); self.reset_modal_geometry();
Task::none() Task::none()

View file

@ -3845,22 +3845,77 @@ impl OpenCADStudio {
Task::none() 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() { if let Some(state) = self.qselect.as_mut() {
// Drop the property when it no longer applies to the state.scope = scope;
// chosen type: type-specific fields like `start_x` state.available_types = available_types;
// would otherwise stay selected but never match. state.available_properties = available_properties;
let kept_property = state.property.clone().and_then(|p| { state.candidate_count = candidate_count;
let i = self.active_tab; state.type_filter = type_filter;
let props = self.tabs[i].scene.qselect_properties(t.as_deref()); state.property = state.property.as_ref().and_then(|selected| {
if props.iter().any(|(f, _)| f == &p.field) { state
Some(p) .available_properties
} else { .iter()
None .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.type_filter = t;
state.available_properties = properties;
state.property = kept_property; 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() Task::none()
} }
@ -3868,6 +3923,15 @@ impl OpenCADStudio {
Message::QSelectSetProperty(p) => { Message::QSelectSetProperty(p) => {
if let Some(state) = self.qselect.as_mut() { if let Some(state) = self.qselect.as_mut() {
state.property = p; 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() Task::none()
} }
@ -3875,6 +3939,7 @@ impl OpenCADStudio {
Message::QSelectSetOperator(op) => { Message::QSelectSetOperator(op) => {
if let Some(state) = self.qselect.as_mut() { if let Some(state) = self.qselect.as_mut() {
state.operator = op; state.operator = op;
state.error = None;
} }
Task::none() Task::none()
} }
@ -3882,29 +3947,91 @@ impl OpenCADStudio {
Message::QSelectSetValue(v) => { Message::QSelectSetValue(v) => {
if let Some(state) = self.qselect.as_mut() { if let Some(state) = self.qselect.as_mut() {
state.value = v; 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() Task::none()
} }
Message::QSelectSetAppend(b) => { Message::QSelectSetAppend(b) => {
if let Some(state) = self.qselect.as_mut() { 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() Task::none()
} }
Message::QSelectApply => { 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 { let Some(state) = self.qselect.take() else {
return Task::none(); return Task::none();
}; };
self.reset_modal_geometry(); self.reset_modal_geometry();
let i = self.active_tab; let i = self.active_tab;
let matched = self.tabs[i].scene.qselect( let matched = self.tabs[i].scene.qselect(
state.scope,
state.type_filter.as_deref(), state.type_filter.as_deref(),
state.property.as_ref().map(|p| p.field.as_str()), state.property.as_ref().map(|p| p.field.as_str()),
state.operator, state.operator,
&state.value, &state.value,
state.append, state.mode,
state.append
&& matches!(state.scope, crate::app::QSelectScope::CurrentSpace),
); );
self.command_line self.command_line
.push_output(crate::tf!("QSELECT: {} object(s) selected.", matched).as_ref()); .push_output(crate::tf!("QSELECT: {} object(s) selected.", matched).as_ref());

View file

@ -1720,12 +1720,11 @@ impl OpenCADStudio {
}; };
let qselect_layer: Element<'_, Message> = if let Some(state) = &self.qselect { 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( qselect_overlay(
state, state,
&types, &state.available_types,
&properties, &state.available_properties,
state.candidate_count,
self.modal_offset, self.modal_offset,
self.modal_resize, self.modal_resize,
) )

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_TYPE: &str = "(Any type)";
const QSELECT_ANY_PROP: &str = "(Any property)"; const QSELECT_ANY_PROP: &str = "(Any property)";
/// Floating panel for the Quick Select feature. Single-row filter: /// Quick Select uses the application's shared movable modal frame. Its form
/// object type → property → operator → value, plus an "Append to current /// groups scope, filter, and result behavior while reusing the same compact
/// selection" checkbox. The property dropdown is type-aware — Common /// field and button styles as the other workspace dialogs.
/// 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.
pub(super) fn qselect_overlay<'a>( pub(super) fn qselect_overlay<'a>(
state: &'a crate::app::QSelectState, state: &'a crate::app::QSelectState,
types: &[String], types: &[String],
properties: &[(String, String)], properties: &[crate::app::QSelectPropertyChoice],
candidate_count: usize,
modal_offset: iced::Vector, modal_offset: iced::Vector,
modal_resize: iced::Vector, modal_resize: iced::Vector,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
@ -1216,12 +1214,14 @@ pub(super) fn qselect_overlay<'a>(
state, state,
types, types,
properties, properties,
candidate_count,
crate::ui::modal::ModalSizing::INTRINSIC, crate::ui::modal::ModalSizing::INTRINSIC,
); );
let content = qselect_content( let content = qselect_content(
state, state,
types, types,
properties, properties,
candidate_count,
crate::ui::modal::ModalSizing::FILL, crate::ui::modal::ModalSizing::FILL,
); );
let content = crate::ui::modal::intrinsic( let content = crate::ui::modal::intrinsic(
@ -1244,10 +1244,11 @@ pub(super) fn qselect_overlay<'a>(
fn qselect_content<'a>( fn qselect_content<'a>(
state: &'a crate::app::QSelectState, state: &'a crate::app::QSelectState,
types: &[String], types: &[String],
properties: &[(String, String)], properties: &[crate::app::QSelectPropertyChoice],
candidate_count: usize,
sizing: crate::ui::modal::ModalSizing, sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> { ) -> 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()]; let mut type_options: Vec<String> = vec![QSELECT_ANY_TYPE.to_string()];
type_options.extend(types.iter().cloned()); type_options.extend(types.iter().cloned());
@ -1255,21 +1256,22 @@ fn qselect_content<'a>(
vec![crate::app::QSelectPropertyChoice { vec![crate::app::QSelectPropertyChoice {
field: String::new(), field: String::new(),
label: QSELECT_ANY_PROP.to_string(), label: QSELECT_ANY_PROP.to_string(),
editor: crate::app::QSelectValueEditor::Text,
}]; }];
prop_options.extend(properties.iter().map(|(field, label)| { prop_options.extend(properties.iter().cloned());
crate::app::QSelectPropertyChoice {
field: field.clone(),
label: label.clone(),
}
}));
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::Eq,
crate::app::QSelectOp::Neq, 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 let type_sel = state
.type_filter .type_filter
@ -1281,11 +1283,9 @@ fn qselect_content<'a>(
.unwrap_or(crate::app::QSelectPropertyChoice { .unwrap_or(crate::app::QSelectPropertyChoice {
field: String::new(), field: String::new(),
label: QSELECT_ANY_PROP.to_string(), 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 = let value_enabled =
state.property.is_some() && !matches!(state.operator, crate::app::QSelectOp::Any); state.property.is_some() && !matches!(state.operator, crate::app::QSelectOp::Any);
let field_width = if matches!(sizing.width, iced::Length::Fill) { let field_width = if matches!(sizing.width, iced::Length::Fill) {
@ -1293,49 +1293,121 @@ fn qselect_content<'a>(
} else { } else {
iced::Length::Shrink 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>| { let label = |s: std::borrow::Cow<'static, str>| {
text(s) text(s)
.size(12) .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| { let value_editor: Element<'a, Message> = match state.property.as_ref() {
button(text(lbl).size(12)) Some(property) => match &property.editor {
.on_press(msg) crate::app::QSelectValueEditor::Choice(options) => {
.style(move |theme: &Theme, st| { let selected = (!state.value.is_empty()).then(|| state.value.clone());
let palette = theme.palette(); let picker = iced::widget::pick_list(
let pair = match ( selected,
primary, options.clone(),
matches!(st, button::Status::Hovered | button::Status::Pressed), |value| value.to_string(),
) { )
(true, true) => palette.primary.strong, .width(field_width);
(true, false) => palette.primary.base, if value_enabled {
(false, true) => palette.background.strong, picker.on_select(Message::QSelectSetValue).into()
(false, false) => palette.background.weak, } else {
}; picker.into()
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()
} }
}) }
.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) let derived_error = if candidate_count == 0 {
.size(12) Some(crate::t!("No objects are available in this scope.").into_owned())
.width(field_width); } else if value_enabled && number_property
if value_enabled { && crate::entities::common::parse_f64(&state.value).is_none()
value_input = value_input.on_input(Message::QSelectSetValue); {
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![ 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![ row![
label(t!("Object type:")), label(t!("Object type:")),
iced::widget::pick_list( iced::widget::pick_list(
@ -1364,12 +1436,12 @@ fn qselect_content<'a>(
|value| value.to_string(), |value| value.to_string(),
) )
.on_select(|p: crate::app::QSelectPropertyChoice| { .on_select(|p: crate::app::QSelectPropertyChoice| {
if p.field.is_empty() { if p.field.is_empty() {
Message::QSelectSetProperty(None) Message::QSelectSetProperty(None)
} else { } else {
Message::QSelectSetProperty(Some(p)) Message::QSelectSetProperty(Some(p))
} }
}) })
.width(field_width), .width(field_width),
] ]
.align_y(iced::Alignment::Center) .align_y(iced::Alignment::Center)
@ -1390,25 +1462,57 @@ fn qselect_content<'a>(
.spacing(8) .spacing(8)
.width(sizing.width), .width(sizing.width),
Space::new().height(6), Space::new().height(6),
row![label(t!("Value:")), value_input,] row![label(t!("Value:")), value_editor]
.align_y(iced::Alignment::Center) .align_y(iced::Alignment::Center)
.spacing(8) .spacing(8)
.width(sizing.width), .width(sizing.width),
Space::new().height(10), 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![ row![
checkbox(state.append) append,
.on_toggle(Message::QSelectSetAppend)
.size(14),
Space::new().width(6), 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), .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![ row![
Space::new().width(field_width), Space::new().width(flex_width),
btn(t!("Cancel"), Message::QSelectClose, false), cancel,
Space::new().width(8), Space::new().width(8),
btn(t!("Apply"), Message::QSelectApply, true), apply,
] ]
.align_y(iced::Alignment::Center), .align_y(iced::Alignment::Center),
] ]
@ -1423,13 +1527,13 @@ fn qselect_content<'a>(
.style(|theme: &Theme| { .style(|theme: &Theme| {
let palette = theme.palette(); let palette = theme.palette();
container::Style { container::Style {
background: Some(Background::Color(palette.background.weak.color)), background: Some(Background::Color(palette.background.weak.color)),
border: Border { border: Border {
color: palette.background.neutral.color, color: palette.background.neutral.color,
width: 1.0, width: 1.0,
radius: 6.0.into(), radius: 6.0.into(),
}, },
..Default::default() ..Default::default()
} }
}); });

View file

@ -56,6 +56,30 @@ impl Scene {
.unwrap_or_default() .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 /// Extends the current selection with every entity in the active
/// layout that matches one of the selected entities by `(variant, /// layout that matches one of the selected entities by `(variant,
/// layer)`. The seed selection stays selected. No-op when nothing is /// layer)`. The seed selection stays selected. No-op when nothing is
@ -112,9 +136,9 @@ impl Scene {
self.selected.len() self.selected.len()
} }
/// Replaces (or extends, when `append` is true) the current /// Builds a selection from the chosen scope and filter. Exclude mode
/// selection with every entity in the active layout that matches /// keeps candidates that do not match. Append mode unions the result
/// the filter. Returns the number of newly-matching entities. /// with the selection that existed before the query.
/// ///
/// `type_name` of `None` means "any type". `property_field` of /// `type_name` of `None` means "any type". `property_field` of
/// `None` skips the property test (only the type filter applies). /// `None` skips the property test (only the type filter applies).
@ -123,19 +147,19 @@ impl Scene {
/// reject anything non-numeric. /// reject anything non-numeric.
pub fn qselect( pub fn qselect(
&mut self, &mut self,
scope: crate::app::QSelectScope,
type_name: Option<&str>, type_name: Option<&str>,
property_field: Option<&str>, property_field: Option<&str>,
op: crate::app::QSelectOp, op: crate::app::QSelectOp,
value: &str, value: &str,
mode: crate::app::QSelectMode,
append: bool, append: bool,
) -> usize { ) -> usize {
use crate::app::QSelectOp; use crate::app::{QSelectMode, QSelectOp};
use crate::entities::traits::entity_type_name; use crate::entities::traits::entity_type_name;
if !append { let previous = self.selected.clone();
self.selected.clear(); let handles = self.qselect_candidate_handles(scope);
} let mut result = HashSet::default();
let handles = self.current_layout_entity_handles();
let mut matched = 0;
for h in handles { for h in handles {
let Some(e) = self.document.get_entity(h) else { let Some(e) = self.document.get_entity(h) else {
continue; continue;
@ -150,43 +174,48 @@ impl Scene {
{ {
continue; continue;
} }
if let Some(t) = type_name { let type_ok = type_name.is_none_or(|t| entity_type_name(e) == t);
if entity_type_name(e) != t { let prop_ok = if !type_ok {
continue; true
} } else {
} match (property_field, op) {
let prop_ok = match (property_field, op) { (None, _) | (_, QSelectOp::Any) => true,
(None, _) | (_, QSelectOp::Any) => true, (Some(field), op) => {
(Some(field), op) => { match self.entity_property_value(e, field) {
let Some(actual) = self.entity_property_value(e, field) else { Some(actual) => match op {
continue; QSelectOp::Eq => actual.eq_ignore_ascii_case(value),
}; QSelectOp::Neq => !actual.eq_ignore_ascii_case(value),
match op { QSelectOp::Gt | QSelectOp::Lt => {
QSelectOp::Eq => actual.eq_ignore_ascii_case(value), match (
QSelectOp::Neq => !actual.eq_ignore_ascii_case(value), crate::entities::common::parse_f64(&actual),
QSelectOp::Gt | QSelectOp::Lt => { crate::entities::common::parse_f64(value),
let (Ok(a), Ok(b)) = ) {
(actual.parse::<f64>(), value.parse::<f64>()) (Some(a), Some(b)) if matches!(op, QSelectOp::Gt) => a > b,
else { (Some(a), Some(b)) => a < b,
continue; _ => false,
}; }
if matches!(op, QSelectOp::Gt) { }
a > b QSelectOp::Any => true,
} else { },
a < b None => false,
}
} }
QSelectOp::Any => true,
} }
} }
}; };
if prop_ok { let matches_filter = type_ok && prop_ok;
self.selected.insert(h); let keep = match mode {
matched += 1; QSelectMode::Include => matches_filter,
QSelectMode::Exclude => !matches_filter,
};
if keep {
result.insert(h);
} }
} }
self.bump_selection(); if append {
matched result.extend(previous);
}
self.replace_selection(result);
self.selected.len()
} }
/// Returns the sorted set of entity-type names present in the active /// Returns the sorted set of entity-type names present in the active
@ -223,25 +252,100 @@ impl Scene {
!self.selection_filter.is_empty() !self.selection_filter.is_empty()
} }
/// Returns the list of `(field, label)` pairs the Quick Select /// Returns common and type-specific filter properties with the editor
/// "Properties" dropdown should show given the current type filter: /// each value needs. Choice editors are enriched from both document
/// /// tables and values present in the selected candidate scope.
/// * Common properties (Layer, Color, Linetype, Lineweight) are
/// always included.
/// * When `type_name` names a specific entity type present in the
/// active layout, the first entity of that type contributes its
/// `geometry_properties()` rows (Start X, Length, Radius, …) so
/// type-specific filtering works.
pub fn qselect_properties( pub fn qselect_properties(
&self, &self,
type_name: Option<&str>, type_name: Option<&str>,
) -> Vec<(String, String)> { scope: crate::app::QSelectScope,
) -> Vec<crate::app::QSelectPropertyChoice> {
use crate::entities::traits::{entity_type_name, EntityTypeOps}; use crate::entities::traits::{entity_type_name, EntityTypeOps};
let mut out: Vec<(String, String)> = vec![ use crate::scene::model::object::PropValue;
("layer".to_string(), "Layer".to_string()), use crate::app::{QSelectPropertyChoice, QSelectValueEditor};
("color".to_string(), "Color".to_string()),
("linetype".to_string(), "Linetype".to_string()), let candidate_handles: Vec<Handle> = self
("lineweight".to_string(), "Lineweight".to_string()), .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 { if let Some(t) = type_name {
let text_style_names: Vec<String> = self let text_style_names: Vec<String> = self
@ -250,22 +354,146 @@ impl Scene {
.iter() .iter()
.map(|s| s.name.clone()) .map(|s| s.name.clone())
.collect(); .collect();
let sample = self let sample = candidate_handles
.current_layout_entity_handles() .iter()
.into_iter() .copied()
.filter_map(|h| self.document.get_entity(h)) .filter_map(|h| self.document.get_entity(h))
.find(|e| entity_type_name(e) == t); .find(|e| entity_type_name(e) == t);
if let Some(sample) = sample { 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 { for prop in section.props {
// Skip rows that don't sensibly compare via if out.iter().any(|item| item.field == prop.field) {
// `entity_property_value` (read-only labels are continue;
// fine — users can still match against them). }
out.push((prop.field.to_string(), prop.label.clone())); 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 out
} }
@ -283,10 +511,53 @@ impl Scene {
use crate::entities::traits::EntityTypeOps; use crate::entities::traits::EntityTypeOps;
use crate::scene::model::object::PropValue; use crate::scene::model::object::PropValue;
match field { match field {
"handle" => Some(entity.common().handle.value().to_string()),
"layer" => Some(entity.common().layer.clone()), "layer" => Some(entity.common().layer.clone()),
"color" => Some(Self::format_color(entity.common().color)), "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)), "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 let text_style_names: Vec<String> = self
.document .document
@ -294,8 +565,14 @@ impl Scene {
.iter() .iter()
.map(|s| s.name.clone()) .map(|s| s.name.clone())
.collect(); .collect();
let prop = entity let mut sections = vec![crate::scene::cache::properties::general_section(entity)];
.geometry_properties(&text_style_names) 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() .into_iter()
.flat_map(|s| s.props) .flat_map(|s| s.props)
.find(|p| p.field == field)?; .find(|p| p.field == field)?;