forked from 1b-agent/opencadstudio-upstream
Merge pull request #1676 from gianlucafiore/feature/quick-layer-search
feat: add quick searchable layer command
This commit is contained in:
15 files changed
+637
-25
No files matched your search
+2
-1
@@ -80,7 +80,7 @@ const WEB_ALIAS_KEY: &str = "opencadstudio.aliases";
|
||||
/// migrated forward (see `introduced_at` / `migrate_aliases`). Version 1 shipped
|
||||
/// the pre-REDRAW table; version 2 introduced the REDRAW-family aliases; version 3
|
||||
/// introduced HB (HATCHTOBACK); version 4 introduced ER (EXTERNALREFERENCES).
|
||||
const DEFAULT_ALIASES_VERSION: u32 = 5;
|
||||
const DEFAULT_ALIASES_VERSION: u32 = 6;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const WEB_ALIAS_VERSION_KEY: &str = "opencadstudio.aliases.version";
|
||||
@@ -100,6 +100,7 @@ fn introduced_at(version: u32) -> &'static [(&'static str, &'static str)] {
|
||||
3 => &[("HB", "HATCHTOBACK")],
|
||||
4 => &[("ER", "EXTERNALREFERENCES")],
|
||||
5 => &[("-ATT", "-ATTDEF")],
|
||||
6 => &[("VV", "QLAYER")],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +204,7 @@ impl OpenCADStudio {
|
||||
if was_active && self.tabs[i].active_cmd.is_none() {
|
||||
self.tabs[i].scene.set_hover_highlight(None);
|
||||
self.command_line.set_step_options(Vec::new());
|
||||
self.command_line.clear_contextual_suggestions();
|
||||
self.restore_add_selected_defaults();
|
||||
self.tabs[i].pending_pause_tokens = None;
|
||||
}
|
||||
|
||||
@@ -628,6 +628,119 @@ impl OpenCADStudio {
|
||||
.push_info(crate::t!("LAYUNISO: all layers restored.").as_ref());
|
||||
}
|
||||
|
||||
"QLAYER" => {
|
||||
use crate::modules::draw::layers::quick_layer::{
|
||||
QuickLayerCommand,
|
||||
QuickLayerEntry,
|
||||
};
|
||||
|
||||
let layers: Vec<QuickLayerEntry> = self.tabs[i]
|
||||
.layers
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|layer| !layer.name.starts_with('*'))
|
||||
.map(|layer| {
|
||||
QuickLayerEntry::new(
|
||||
layer.name.clone(),
|
||||
layer.color.clone(),
|
||||
layer.visible,
|
||||
layer.frozen,
|
||||
layer.locked,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let selected: Vec<codec::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(handle, _)| handle)
|
||||
.collect();
|
||||
|
||||
let command = QuickLayerCommand::new_with_entries(layers, selected);
|
||||
self.command_line.push_info(&command.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||
}
|
||||
|
||||
cmd if cmd.starts_with("QLAYER_APPLY_HEX ") => {
|
||||
use crate::modules::draw::layers::quick_layer::QuickLayerCommand;
|
||||
|
||||
let encoded = cmd.trim_start_matches("QLAYER_APPLY_HEX").trim();
|
||||
|
||||
let Some(requested) = QuickLayerCommand::decode_layer_name(encoded) else {
|
||||
self.command_line.push_error(
|
||||
crate::t!("QLAYER: invalid encoded layer name.").as_ref(),
|
||||
);
|
||||
return Some(Task::none());
|
||||
};
|
||||
|
||||
let resolved = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.layers
|
||||
.names()
|
||||
.find(|name| name.eq_ignore_ascii_case(&requested))
|
||||
.map(|name| name.to_string());
|
||||
|
||||
let Some(layer) = resolved else {
|
||||
self.command_line.push_error(
|
||||
crate::tf!("QLAYER: no layer named \"{requested}\".").as_ref(),
|
||||
);
|
||||
return Some(Task::none());
|
||||
};
|
||||
|
||||
let handles: Vec<codec::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(handle, _)| handle)
|
||||
.collect();
|
||||
|
||||
if handles.is_empty() {
|
||||
match self.set_current_layer_name(i, &layer) {
|
||||
Ok(()) => {
|
||||
self.command_line.push_info(
|
||||
crate::tf!(
|
||||
"QLAYER: current layer set to \"{layer}\"."
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
self.command_line.push_error(&error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let count = handles.len();
|
||||
|
||||
self.apply_property_op(
|
||||
i,
|
||||
"QLAYER",
|
||||
&handles,
|
||||
|app, handle| {
|
||||
if let Some(entity) =
|
||||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
crate::scene::view::dispatch::apply_common_prop(
|
||||
entity,
|
||||
"layer",
|
||||
&layer,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
self.ribbon.active_layer = layer.clone();
|
||||
|
||||
self.command_line.push_info(
|
||||
crate::tf!(
|
||||
"QLAYER: moved {count} object(s) to layer \"{layer}\"."
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
"LAYMATCH" | "LAYMCH" => {
|
||||
use crate::modules::draw::layers::match_layer::LayMatchCommand;
|
||||
let dest: Vec<_> = self.tabs[i]
|
||||
|
||||
+4
-1
@@ -2600,8 +2600,11 @@ pub enum Message {
|
||||
/// Pop the trailing character off the command-line input — backspace
|
||||
/// counterpart to `CommandAppendChar`.
|
||||
CommandBackspace,
|
||||
/// TAB pressed: move focus to the next dynamic-input field (wraps).
|
||||
/// TAB pressed: move focus to the next dynamic-input field or
|
||||
/// autocomplete suggestion.
|
||||
DynTabNext,
|
||||
/// Shift+TAB: move to the previous contextual autocomplete suggestion.
|
||||
DynTabPrev,
|
||||
/// Split the active Model viewport in two. `true` → horizontal divider
|
||||
/// (top / bottom); `false` → vertical divider (left / right).
|
||||
SplitModelViewport(bool),
|
||||
|
||||
@@ -50,6 +50,7 @@ pub(super) fn default_bindings() -> BTreeMap<String, String> {
|
||||
("DELETE".to_string(), "DELETESELECTED"),
|
||||
("BACKSPACE".to_string(), "BACKSPACE"),
|
||||
("TAB".to_string(), "DYNTAB"),
|
||||
("SHIFT+TAB".to_string(), "DYNTABPREV"),
|
||||
("UP".to_string(), "HISTORYPREV"),
|
||||
("DOWN".to_string(), "HISTORYNEXT"),
|
||||
("LEFT".to_string(), "CARETLEFT"),
|
||||
@@ -154,6 +155,7 @@ pub(super) const INPUT_ACTIONS: &[&str] = &[
|
||||
"DELETESELECTED",
|
||||
"BACKSPACE",
|
||||
"DYNTAB",
|
||||
"DYNTABPREV",
|
||||
"HISTORYPREV",
|
||||
"HISTORYNEXT",
|
||||
"CARETLEFT",
|
||||
@@ -310,6 +312,7 @@ impl OpenCADStudio {
|
||||
"DELETESELECTED" => Message::DeleteSelected,
|
||||
"BACKSPACE" => Message::CommandBackspace,
|
||||
"DYNTAB" => Message::DynTabNext,
|
||||
"DYNTABPREV" => Message::DynTabPrev,
|
||||
"HISTORYPREV" => Message::CommandHistoryPrev,
|
||||
"HISTORYNEXT" => Message::CommandHistoryNext,
|
||||
"CARETLEFT" => Message::MTextCaretMove(-1),
|
||||
|
||||
@@ -303,9 +303,22 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
// Live incremental search for INSERT/MINSERT (see CommandInput)
|
||||
let live = self.command_line.input.clone();
|
||||
let i = self.active_tab;
|
||||
let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
|
||||
if cmd.on_live_input(&live) { (true, cmd.options(), cmd.prompt()) } else { (false, Vec::new(), String::new()) }
|
||||
} else { (false, Vec::new(), String::new()) };
|
||||
let (should_update, opts, prompt, suggestions) =
|
||||
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
|
||||
let changed = cmd.on_live_input(&live);
|
||||
let suggestions = cmd.text_suggestions(&live);
|
||||
if changed {
|
||||
(true, cmd.options(), cmd.prompt(), suggestions)
|
||||
} else {
|
||||
(false, Vec::new(), String::new(), suggestions)
|
||||
}
|
||||
} else {
|
||||
(false, Vec::new(), String::new(), Vec::new())
|
||||
};
|
||||
|
||||
self.command_line
|
||||
.set_contextual_suggestions(suggestions);
|
||||
|
||||
if should_update {
|
||||
self.command_line.set_step_options(opts);
|
||||
if let Some(last) = self.command_line.history.last_mut() { if last.pinned { last.text = prompt; } }
|
||||
@@ -727,6 +740,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
self.command_line.autocomplete_cursor = None;
|
||||
return self.dispatch_command(&command);
|
||||
}
|
||||
} else if self.command_line.has_contextual_suggestions() {
|
||||
// During an active command Enter accepts the highlighted
|
||||
// contextual value instead of the user's partial search.
|
||||
if let Some(value) = self.command_line.selected_suggestion() {
|
||||
self.command_line.input = value;
|
||||
}
|
||||
}
|
||||
let i = self.active_tab;
|
||||
// A whole multi-token command line (`UCS Z 90`, `LINE 0,0
|
||||
|
||||
@@ -258,6 +258,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
},
|
||||
Message::DynTabNext => Some(self.on_context_menu_navigate(ContextMenuNav::Down)),
|
||||
Message::DynTabPrev => Some(self.on_context_menu_navigate(ContextMenuNav::Up)),
|
||||
Message::CommandFinalize | Message::CommandSpace => {
|
||||
if self.command_line.input.trim().is_empty() {
|
||||
Some(self.on_context_menu_navigate(ContextMenuNav::Enter))
|
||||
|
||||
@@ -1359,6 +1359,9 @@ impl OpenCADStudio {
|
||||
self.shortcut_bindings
|
||||
.entry("F5".to_string())
|
||||
.or_insert_with(|| "ISOPLANE".to_string());
|
||||
self.shortcut_bindings
|
||||
.entry("SHIFT+TAB".to_string())
|
||||
.or_insert_with(|| "DYNTABPREV".to_string());
|
||||
}
|
||||
|
||||
/// Write the config only when it changed since the last write, so a toggle
|
||||
|
||||
+41
-10
@@ -24,6 +24,7 @@ fn is_modal_blocked_key_msg(msg: &Message) -> bool {
|
||||
| Message::CommandLineArrowProbe { .. }
|
||||
| Message::CommandLineArrowResolved { .. }
|
||||
| Message::DynTabNext
|
||||
| Message::DynTabPrev
|
||||
| Message::MTextCaretMove(_)
|
||||
| Message::DeleteSelected
|
||||
| Message::ToggleSnapEnabled
|
||||
@@ -2156,16 +2157,22 @@ impl OpenCADStudio {
|
||||
let i = self.active_tab;
|
||||
// Capture prompt/options while holding cmd borrow, then release before
|
||||
// borrowing command_line to satisfy borrow checker.
|
||||
let (should_update, opts, prompt) =
|
||||
let (should_update, opts, prompt, suggestions) =
|
||||
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
|
||||
if cmd.on_live_input(&live_input) {
|
||||
(true, cmd.options(), cmd.prompt())
|
||||
let changed = cmd.on_live_input(&live_input);
|
||||
let suggestions = cmd.text_suggestions(&live_input);
|
||||
if changed {
|
||||
(true, cmd.options(), cmd.prompt(), suggestions)
|
||||
} else {
|
||||
(false, Vec::new(), String::new())
|
||||
(false, Vec::new(), String::new(), suggestions)
|
||||
}
|
||||
} else {
|
||||
(false, Vec::new(), String::new())
|
||||
(false, Vec::new(), String::new(), Vec::new())
|
||||
};
|
||||
|
||||
self.command_line
|
||||
.set_contextual_suggestions(suggestions);
|
||||
|
||||
if should_update {
|
||||
self.command_line.set_step_options(opts);
|
||||
// Update pinned prompt text live without pushing new history entry
|
||||
@@ -2194,6 +2201,12 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
Message::DynTabNext => {
|
||||
if self.command_line.has_contextual_suggestions()
|
||||
&& self.command_line.autocomplete_next()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
let i = self.active_tab;
|
||||
let n = self.tabs[i].dyn_fields.len();
|
||||
if n > 0 {
|
||||
@@ -2205,6 +2218,13 @@ impl OpenCADStudio {
|
||||
self.focus_cmd_input()
|
||||
}
|
||||
|
||||
Message::DynTabPrev => {
|
||||
if self.command_line.has_contextual_suggestions() {
|
||||
self.command_line.autocomplete_prev();
|
||||
}
|
||||
self.focus_cmd_input()
|
||||
}
|
||||
|
||||
Message::SplitModelViewport(horizontal) => {
|
||||
let i = self.active_tab;
|
||||
self.tabs[i].scene.split_active_pane(horizontal);
|
||||
@@ -2241,7 +2261,8 @@ impl OpenCADStudio {
|
||||
}
|
||||
let i = self.active_tab;
|
||||
if !self.command_line.history_navigation_active()
|
||||
&& self.tabs[i].active_cmd.is_none()
|
||||
&& (self.tabs[i].active_cmd.is_none()
|
||||
|| self.command_line.has_contextual_suggestions())
|
||||
&& self.command_line.autocomplete_prev()
|
||||
{
|
||||
return Task::none();
|
||||
@@ -2267,7 +2288,8 @@ impl OpenCADStudio {
|
||||
}
|
||||
let i = self.active_tab;
|
||||
if !self.command_line.history_navigation_active()
|
||||
&& self.tabs[i].active_cmd.is_none()
|
||||
&& (self.tabs[i].active_cmd.is_none()
|
||||
|| self.command_line.has_contextual_suggestions())
|
||||
&& self.command_line.autocomplete_next()
|
||||
{
|
||||
return Task::none();
|
||||
@@ -2468,11 +2490,20 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
Message::CommandSuggestionPick(cmd) => {
|
||||
Message::CommandSuggestionPick(value) => {
|
||||
self.command_line.close_history();
|
||||
|
||||
if self.tabs[self.active_tab].active_cmd.is_some()
|
||||
&& self.command_line.has_contextual_suggestions()
|
||||
{
|
||||
self.command_line.input = value;
|
||||
self.command_line.autocomplete_cursor = None;
|
||||
return self.on_command_submit();
|
||||
}
|
||||
|
||||
self.command_line.input.clear();
|
||||
self.command_line.autocomplete_cursor = None;
|
||||
self.command_line.close_history();
|
||||
self.dispatch_command(&cmd)
|
||||
self.dispatch_command(&value)
|
||||
}
|
||||
|
||||
Message::CommandOptionPick(kw) => {
|
||||
|
||||
+3
-1
@@ -1924,7 +1924,9 @@ bg={bg_ms:.1}ms n={view_count}"
|
||||
// collecting its own input. A pending Lengthen / Radius / Arc Length
|
||||
// value is numeric input, not the prefix of a new command (for example,
|
||||
// `3` must not open the 3DALIGN / 3DARRAY suggestions).
|
||||
let allow_autocomplete = tab.active_cmd.is_none() && self.grip_pending.is_none();
|
||||
let allow_autocomplete = self.grip_pending.is_none()
|
||||
&& (tab.active_cmd.is_none()
|
||||
|| self.command_line.has_contextual_suggestions());
|
||||
// Dynamic input captures keystrokes when its fields are showing,
|
||||
// so the command-line field must release focus / its on_input.
|
||||
// The MText preview also captures keystrokes (typing edits it), so the
|
||||
|
||||
Reference in new issue
Block a user