forked from 1b-agent/opencadstudio-upstream
feat: Add advanced selection filter
This commit is contained in:
1 parent
7c61583ac2
commit
09cf99f85c
8 files changed
+621
-80
No files matched your search
@@ -2111,9 +2111,8 @@ impl OpenCADStudio {
|
||||
pub(super) fn paste_clipboard_image(&mut self, name: String, bytes: Vec<u8>) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
if self.tabs[i].is_start {
|
||||
self.command_line.push_error(
|
||||
crate::tr!("common", "no-drawing-open-use-new-or-open-first").as_ref(),
|
||||
);
|
||||
self.command_line
|
||||
.push_error(crate::tr!("common", "no-drawing-open-use-new-or-open-first").as_ref());
|
||||
return Task::none();
|
||||
}
|
||||
crate::scene::model::image_model::register_source(&name, std::sync::Arc::new(bytes));
|
||||
@@ -2227,6 +2226,10 @@ impl OpenCADStudio {
|
||||
let append = matches!(scope, crate::app::QSelectScope::CurrentSpace)
|
||||
&& remembered.as_ref().is_some_and(|settings| settings.append);
|
||||
self.qselect = Some(crate::app::QSelectState {
|
||||
conditions: remembered
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |settings| settings.conditions.clone()),
|
||||
editing_condition: None,
|
||||
scope,
|
||||
available_types,
|
||||
available_properties,
|
||||
|
||||
+48
-27
@@ -511,12 +511,15 @@ impl OpenCADStudio {
|
||||
.as_ref()
|
||||
.map(|c| c.options())
|
||||
.or_else(|| {
|
||||
self.graphic_attributes.pending_fill_close.as_ref().map(|_| {
|
||||
vec![
|
||||
crate::command::CmdOption::new("Yes", "Y"),
|
||||
crate::command::CmdOption::new("No", "N"),
|
||||
]
|
||||
})
|
||||
self.graphic_attributes
|
||||
.pending_fill_close
|
||||
.as_ref()
|
||||
.map(|_| {
|
||||
vec![
|
||||
crate::command::CmdOption::new("Yes", "Y"),
|
||||
crate::command::CmdOption::new("No", "N"),
|
||||
]
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
self.command_line.set_step_options(opts);
|
||||
@@ -747,9 +750,7 @@ impl OpenCADStudio {
|
||||
// A settled fetch's rebuild trigger was dropped at the
|
||||
// outcome cap; do one unconditional catch-up pass over
|
||||
// every registered name.
|
||||
registered.extend(
|
||||
crate::scene::model::image_model::registered_image_names(),
|
||||
);
|
||||
registered.extend(crate::scene::model::image_model::registered_image_names());
|
||||
}
|
||||
if !registered.is_empty() {
|
||||
for tab in self.tabs.iter_mut() {
|
||||
@@ -925,9 +926,8 @@ impl OpenCADStudio {
|
||||
// (its WebFileOpened then fails the id check and the
|
||||
// bytes are lost) — refuse like on_open_file does.
|
||||
if self.opening.is_some() {
|
||||
self.command_line.push_info(
|
||||
crate::t!("An open is already in progress.").as_ref(),
|
||||
);
|
||||
self.command_line
|
||||
.push_info(crate::t!("An open is already in progress.").as_ref());
|
||||
return Task::none();
|
||||
}
|
||||
let state = std::sync::Arc::new(crate::io::OpenProgressState::new(
|
||||
@@ -1422,7 +1422,7 @@ impl OpenCADStudio {
|
||||
LinkOutcome::Error(text) => self.command_line.push_error(&text),
|
||||
}
|
||||
Task::none()
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Message::WebAutosavesScanned(result) => self.on_web_autosaves_scanned(result),
|
||||
@@ -1697,10 +1697,7 @@ impl OpenCADStudio {
|
||||
)
|
||||
.add_filter(crate::t!("DWG Files").as_ref(), &["dwg", "DWG"])
|
||||
.add_filter(crate::t!("DXF Files").as_ref(), &["dxf", "DXF"])
|
||||
.add_filter(
|
||||
crate::t!("Backup Files").as_ref(),
|
||||
&["bak", "BAK"],
|
||||
)
|
||||
.add_filter(crate::t!("Backup Files").as_ref(), &["bak", "BAK"])
|
||||
.pick_file()
|
||||
.await;
|
||||
match handle {
|
||||
@@ -7094,17 +7091,26 @@ impl OpenCADStudio {
|
||||
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); }
|
||||
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());
|
||||
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);
|
||||
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;
|
||||
@@ -7148,14 +7154,24 @@ impl OpenCADStudio {
|
||||
}
|
||||
Message::QSelectApply => {
|
||||
let validation_error = self.qselect.as_ref().and_then(|state| {
|
||||
if self.tabs[self.active_tab].scene.qselect_candidate_count(state.scope) == 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 state.editing_condition.is_some() {
|
||||
Some(crate::t!("Update or cancel the condition edit before applying.").into_owned())
|
||||
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 {
|
||||
state.conditions.iter().find_map(|condition| condition.validation_error())
|
||||
state
|
||||
.conditions
|
||||
.iter()
|
||||
.find_map(|condition| condition.validation_error())
|
||||
}
|
||||
});
|
||||
if let Some(error) = validation_error {
|
||||
@@ -7172,9 +7188,13 @@ impl OpenCADStudio {
|
||||
let i = self.active_tab;
|
||||
let conditions = if state.conditions.is_empty() {
|
||||
vec![state.draft_condition()]
|
||||
} else { state.conditions.clone() };
|
||||
} else {
|
||||
state.conditions.clone()
|
||||
};
|
||||
let matched = self.tabs[i].scene.qselect_conditions(
|
||||
state.scope, &conditions, state.mode,
|
||||
state.scope,
|
||||
&conditions,
|
||||
state.mode,
|
||||
state.append && matches!(state.scope, crate::app::QSelectScope::CurrentSpace),
|
||||
);
|
||||
self.command_line
|
||||
@@ -7648,7 +7668,8 @@ impl OpenCADStudio {
|
||||
crate::ui::window::graphic_attributes::LINETYPE_SCALE_FIELD,
|
||||
);
|
||||
if focused.as_ref() == Some(&scale_field) {
|
||||
if !std::mem::replace(&mut self.graphic_attributes.linetype_scale_focused, true) {
|
||||
if !std::mem::replace(&mut self.graphic_attributes.linetype_scale_focused, true)
|
||||
{
|
||||
return iced::widget::operation::select_all(scale_field);
|
||||
}
|
||||
return Task::none();
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Read-only object sampling for the selection-filter dialog.
|
||||
|
||||
use crate::app::{OpenCADStudio, QSelectCondition, QSelectState};
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use codec::Handle;
|
||||
use glam::DVec3;
|
||||
use iced::Task;
|
||||
|
||||
/// The command owns the suspended dialog, so Escape can restore the exact
|
||||
/// draft, committed conditions, scope and result mode without global pick state.
|
||||
struct QSelectSampleCommand {
|
||||
state: Option<Box<QSelectState>>,
|
||||
tab_id: u64,
|
||||
}
|
||||
|
||||
impl QSelectSampleCommand {
|
||||
fn finish(&mut self, handle: Option<Handle>) -> CmdResult {
|
||||
match self.state.take() {
|
||||
Some(state) => CmdResult::QSelectSample {
|
||||
state,
|
||||
tab_id: self.tab_id,
|
||||
handle,
|
||||
},
|
||||
None => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for QSelectSampleCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"FILTER"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
format!("FILTER {}", crate::t!("Select object:"))
|
||||
}
|
||||
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn entity_pick_includes_fills(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn entity_pick_highlights_hover(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn on_entity_pick(&mut self, handle: Handle, _point: DVec3) -> CmdResult {
|
||||
if handle.is_null() {
|
||||
CmdResult::NeedPoint
|
||||
} else {
|
||||
self.finish(Some(handle))
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _point: DVec3) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
self.finish(None)
|
||||
}
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
self.finish(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn append_unique_conditions(target: &mut Vec<QSelectCondition>, incoming: Vec<QSelectCondition>) {
|
||||
for condition in incoming {
|
||||
let duplicate = target.iter().any(|existing| {
|
||||
existing.type_filter == condition.type_filter
|
||||
&& existing.property == condition.property
|
||||
&& existing.operator == condition.operator
|
||||
&& existing.value.eq_ignore_ascii_case(&condition.value)
|
||||
});
|
||||
if !duplicate {
|
||||
target.push(condition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(super) fn on_qselect_pick_object(&mut self) -> Task<crate::app::Message> {
|
||||
// Finish or cancel a condition edit first. Sampling only appends rows.
|
||||
if self
|
||||
.qselect
|
||||
.as_ref()
|
||||
.is_none_or(|state| state.editing_condition.is_some())
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let state = self.qselect.take().expect("checked open filter");
|
||||
// Keep a recoverable copy if another command or a document switch
|
||||
// interrupts picking instead of ending it through Enter/Escape.
|
||||
self.qselect_settings = Some((&state).into());
|
||||
let i = self.active_tab;
|
||||
let command = QSelectSampleCommand {
|
||||
state: Some(Box::new(state)),
|
||||
tab_id: self.tabs[i].id,
|
||||
};
|
||||
self.active_modal = None;
|
||||
self.command_line.input.clear();
|
||||
self.command_line.set_step_options(Vec::new());
|
||||
self.command_line.push_info(&command.prompt());
|
||||
self.tabs[i].scene.selection.borrow_mut().menu.open_at = None;
|
||||
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(in crate::app) fn finish_qselect_sample(
|
||||
&mut self,
|
||||
mut state: QSelectState,
|
||||
tab_id: u64,
|
||||
handle: Option<Handle>,
|
||||
) {
|
||||
let i = self.active_tab;
|
||||
// A handle is only meaningful in its original document.
|
||||
if self.tabs[i].id != tab_id {
|
||||
return;
|
||||
}
|
||||
self.tabs[i].active_cmd = None;
|
||||
self.tabs[i].snap_result = None;
|
||||
self.tabs[i].scene.clear_preview_wire();
|
||||
self.restore_pre_cmd_tangent();
|
||||
if let Some(handle) = handle {
|
||||
let conditions = self.tabs[i].scene.qselect_conditions_from_entity(handle);
|
||||
append_unique_conditions(&mut state.conditions, conditions);
|
||||
state.error = None;
|
||||
}
|
||||
self.qselect = Some(state);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{QSelectMode, QSelectOp, QSelectScope};
|
||||
|
||||
fn state() -> QSelectState {
|
||||
QSelectState {
|
||||
conditions: Vec::new(),
|
||||
editing_condition: None,
|
||||
scope: QSelectScope::CurrentSelection,
|
||||
available_types: Vec::new(),
|
||||
available_properties: Vec::new(),
|
||||
candidate_count: 2,
|
||||
type_filter: Some("Line".into()),
|
||||
property: None,
|
||||
operator: QSelectOp::Eq,
|
||||
value: "unfinished draft".into(),
|
||||
mode: QSelectMode::Exclude,
|
||||
append: false,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_restores_draft_and_scope_without_a_sample() {
|
||||
let mut command = QSelectSampleCommand {
|
||||
state: Some(Box::new(state())),
|
||||
tab_id: 17,
|
||||
};
|
||||
let CmdResult::QSelectSample {
|
||||
state,
|
||||
tab_id,
|
||||
handle,
|
||||
} = command.on_escape()
|
||||
else {
|
||||
panic!("Escape must restore the filter");
|
||||
};
|
||||
assert_eq!(tab_id, 17);
|
||||
assert!(handle.is_none());
|
||||
assert_eq!(state.value, "unfinished draft");
|
||||
assert!(matches!(state.scope, QSelectScope::CurrentSelection));
|
||||
assert!(matches!(state.mode, QSelectMode::Exclude));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_click_keeps_picker_active_and_enter_restores_dialog() {
|
||||
let mut command = QSelectSampleCommand {
|
||||
state: Some(Box::new(state())),
|
||||
tab_id: 17,
|
||||
};
|
||||
assert!(matches!(
|
||||
command.on_entity_pick(Handle::NULL, DVec3::ZERO),
|
||||
CmdResult::NeedPoint
|
||||
));
|
||||
assert!(matches!(
|
||||
command.on_enter(),
|
||||
CmdResult::QSelectSample { handle: None, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_sampling_preserves_existing_conditions_without_duplicates() {
|
||||
let condition = state().draft_condition();
|
||||
let mut conditions = vec![condition.clone()];
|
||||
append_unique_conditions(&mut conditions, vec![condition.clone(), condition]);
|
||||
assert_eq!(conditions.len(), 1);
|
||||
assert_eq!(conditions[0].value, "unfinished draft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_round_trip_keeps_selection_and_dialog_geometry() {
|
||||
use codec::entities::Line;
|
||||
use codec::types::Vector3;
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let _ = app.automation_op(r#"{"op":"new"}"#);
|
||||
let i = app.active_tab;
|
||||
let first = app.tabs[i]
|
||||
.scene
|
||||
.add_entity(codec::EntityType::Line(Line::from_points(
|
||||
Vector3::ZERO,
|
||||
Vector3::new(10.0, 0.0, 0.0),
|
||||
)));
|
||||
let sample = app.tabs[i]
|
||||
.scene
|
||||
.add_entity(codec::EntityType::Line(Line::from_points(
|
||||
Vector3::ZERO,
|
||||
Vector3::new(20.0, 0.0, 0.0),
|
||||
)));
|
||||
app.tabs[i].scene.select_entity(first, false);
|
||||
let selection = app.tabs[i].scene.selected.clone();
|
||||
let dirty = app.tabs[i].dirty;
|
||||
app.qselect = Some(state());
|
||||
app.modal_offset = iced::Vector::new(10.0, 20.0);
|
||||
app.modal_resize = iced::Vector::new(30.0, 40.0);
|
||||
|
||||
let _ = app.on_qselect_pick_object();
|
||||
assert!(app.qselect.is_none());
|
||||
let result = app.tabs[i]
|
||||
.active_cmd
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.on_entity_pick(sample, DVec3::ZERO);
|
||||
let _ = app.apply_cmd_result(result);
|
||||
let restored = app.qselect.as_ref().unwrap();
|
||||
assert!(!restored.conditions.is_empty());
|
||||
assert_eq!(restored.value, "unfinished draft");
|
||||
assert!(matches!(restored.scope, QSelectScope::CurrentSelection));
|
||||
assert_eq!(app.tabs[i].scene.selected, selection);
|
||||
assert_eq!(app.tabs[i].dirty, dirty);
|
||||
assert_eq!(app.modal_offset, iced::Vector::new(10.0, 20.0));
|
||||
assert_eq!(app.modal_resize, iced::Vector::new(30.0, 40.0));
|
||||
assert!(app.tabs[i].active_cmd.is_none());
|
||||
|
||||
let count = restored.conditions.len();
|
||||
let _ = app.on_qselect_pick_object();
|
||||
let result = app.tabs[i].active_cmd.as_mut().unwrap().on_escape();
|
||||
let _ = app.apply_cmd_result(result);
|
||||
assert_eq!(app.qselect.as_ref().unwrap().conditions.len(), count);
|
||||
assert_eq!(app.tabs[i].scene.selected, selection);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user