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
@@ -56,6 +56,7 @@ LEN, *LENGTHEN
|
|||||||
AL, *ALIGN
|
AL, *ALIGN
|
||||||
AR, *ARRAY
|
AR, *ARRAY
|
||||||
MA, *MATCHPROP
|
MA, *MATCHPROP
|
||||||
|
VV, *QLAYER
|
||||||
ATE, *ATTEDIT
|
ATE, *ATTEDIT
|
||||||
|
|
||||||
; ── Clipboard ─────────────────────────────────────────────────────────────
|
; ── Clipboard ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+2
-1
@@ -80,7 +80,7 @@ const WEB_ALIAS_KEY: &str = "opencadstudio.aliases";
|
|||||||
/// migrated forward (see `introduced_at` / `migrate_aliases`). Version 1 shipped
|
/// migrated forward (see `introduced_at` / `migrate_aliases`). Version 1 shipped
|
||||||
/// the pre-REDRAW table; version 2 introduced the REDRAW-family aliases; version 3
|
/// the pre-REDRAW table; version 2 introduced the REDRAW-family aliases; version 3
|
||||||
/// introduced HB (HATCHTOBACK); version 4 introduced ER (EXTERNALREFERENCES).
|
/// introduced HB (HATCHTOBACK); version 4 introduced ER (EXTERNALREFERENCES).
|
||||||
const DEFAULT_ALIASES_VERSION: u32 = 5;
|
const DEFAULT_ALIASES_VERSION: u32 = 6;
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
const WEB_ALIAS_VERSION_KEY: &str = "opencadstudio.aliases.version";
|
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")],
|
3 => &[("HB", "HATCHTOBACK")],
|
||||||
4 => &[("ER", "EXTERNALREFERENCES")],
|
4 => &[("ER", "EXTERNALREFERENCES")],
|
||||||
5 => &[("-ATT", "-ATTDEF")],
|
5 => &[("-ATT", "-ATTDEF")],
|
||||||
|
6 => &[("VV", "QLAYER")],
|
||||||
_ => &[],
|
_ => &[],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ impl OpenCADStudio {
|
|||||||
if was_active && self.tabs[i].active_cmd.is_none() {
|
if was_active && self.tabs[i].active_cmd.is_none() {
|
||||||
self.tabs[i].scene.set_hover_highlight(None);
|
self.tabs[i].scene.set_hover_highlight(None);
|
||||||
self.command_line.set_step_options(Vec::new());
|
self.command_line.set_step_options(Vec::new());
|
||||||
|
self.command_line.clear_contextual_suggestions();
|
||||||
self.restore_add_selected_defaults();
|
self.restore_add_selected_defaults();
|
||||||
self.tabs[i].pending_pause_tokens = None;
|
self.tabs[i].pending_pause_tokens = None;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -628,6 +628,119 @@ impl OpenCADStudio {
|
|||||||
.push_info(crate::t!("LAYUNISO: all layers restored.").as_ref());
|
.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" => {
|
"LAYMATCH" | "LAYMCH" => {
|
||||||
use crate::modules::draw::layers::match_layer::LayMatchCommand;
|
use crate::modules::draw::layers::match_layer::LayMatchCommand;
|
||||||
let dest: Vec<_> = self.tabs[i]
|
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
|
/// Pop the trailing character off the command-line input — backspace
|
||||||
/// counterpart to `CommandAppendChar`.
|
/// counterpart to `CommandAppendChar`.
|
||||||
CommandBackspace,
|
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,
|
DynTabNext,
|
||||||
|
/// Shift+TAB: move to the previous contextual autocomplete suggestion.
|
||||||
|
DynTabPrev,
|
||||||
/// Split the active Model viewport in two. `true` → horizontal divider
|
/// Split the active Model viewport in two. `true` → horizontal divider
|
||||||
/// (top / bottom); `false` → vertical divider (left / right).
|
/// (top / bottom); `false` → vertical divider (left / right).
|
||||||
SplitModelViewport(bool),
|
SplitModelViewport(bool),
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ pub(super) fn default_bindings() -> BTreeMap<String, String> {
|
|||||||
("DELETE".to_string(), "DELETESELECTED"),
|
("DELETE".to_string(), "DELETESELECTED"),
|
||||||
("BACKSPACE".to_string(), "BACKSPACE"),
|
("BACKSPACE".to_string(), "BACKSPACE"),
|
||||||
("TAB".to_string(), "DYNTAB"),
|
("TAB".to_string(), "DYNTAB"),
|
||||||
|
("SHIFT+TAB".to_string(), "DYNTABPREV"),
|
||||||
("UP".to_string(), "HISTORYPREV"),
|
("UP".to_string(), "HISTORYPREV"),
|
||||||
("DOWN".to_string(), "HISTORYNEXT"),
|
("DOWN".to_string(), "HISTORYNEXT"),
|
||||||
("LEFT".to_string(), "CARETLEFT"),
|
("LEFT".to_string(), "CARETLEFT"),
|
||||||
@@ -154,6 +155,7 @@ pub(super) const INPUT_ACTIONS: &[&str] = &[
|
|||||||
"DELETESELECTED",
|
"DELETESELECTED",
|
||||||
"BACKSPACE",
|
"BACKSPACE",
|
||||||
"DYNTAB",
|
"DYNTAB",
|
||||||
|
"DYNTABPREV",
|
||||||
"HISTORYPREV",
|
"HISTORYPREV",
|
||||||
"HISTORYNEXT",
|
"HISTORYNEXT",
|
||||||
"CARETLEFT",
|
"CARETLEFT",
|
||||||
@@ -310,6 +312,7 @@ impl OpenCADStudio {
|
|||||||
"DELETESELECTED" => Message::DeleteSelected,
|
"DELETESELECTED" => Message::DeleteSelected,
|
||||||
"BACKSPACE" => Message::CommandBackspace,
|
"BACKSPACE" => Message::CommandBackspace,
|
||||||
"DYNTAB" => Message::DynTabNext,
|
"DYNTAB" => Message::DynTabNext,
|
||||||
|
"DYNTABPREV" => Message::DynTabPrev,
|
||||||
"HISTORYPREV" => Message::CommandHistoryPrev,
|
"HISTORYPREV" => Message::CommandHistoryPrev,
|
||||||
"HISTORYNEXT" => Message::CommandHistoryNext,
|
"HISTORYNEXT" => Message::CommandHistoryNext,
|
||||||
"CARETLEFT" => Message::MTextCaretMove(-1),
|
"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)
|
// Live incremental search for INSERT/MINSERT (see CommandInput)
|
||||||
let live = self.command_line.input.clone();
|
let live = self.command_line.input.clone();
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
|
let (should_update, opts, prompt, suggestions) =
|
||||||
if cmd.on_live_input(&live) { (true, cmd.options(), cmd.prompt()) } else { (false, Vec::new(), String::new()) }
|
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
|
||||||
} else { (false, Vec::new(), String::new()) };
|
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 {
|
if should_update {
|
||||||
self.command_line.set_step_options(opts);
|
self.command_line.set_step_options(opts);
|
||||||
if let Some(last) = self.command_line.history.last_mut() { if last.pinned { last.text = prompt; } }
|
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;
|
self.command_line.autocomplete_cursor = None;
|
||||||
return self.dispatch_command(&command);
|
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;
|
let i = self.active_tab;
|
||||||
// A whole multi-token command line (`UCS Z 90`, `LINE 0,0
|
// 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::DynTabNext => Some(self.on_context_menu_navigate(ContextMenuNav::Down)),
|
||||||
|
Message::DynTabPrev => Some(self.on_context_menu_navigate(ContextMenuNav::Up)),
|
||||||
Message::CommandFinalize | Message::CommandSpace => {
|
Message::CommandFinalize | Message::CommandSpace => {
|
||||||
if self.command_line.input.trim().is_empty() {
|
if self.command_line.input.trim().is_empty() {
|
||||||
Some(self.on_context_menu_navigate(ContextMenuNav::Enter))
|
Some(self.on_context_menu_navigate(ContextMenuNav::Enter))
|
||||||
|
|||||||
@@ -1359,6 +1359,9 @@ impl OpenCADStudio {
|
|||||||
self.shortcut_bindings
|
self.shortcut_bindings
|
||||||
.entry("F5".to_string())
|
.entry("F5".to_string())
|
||||||
.or_insert_with(|| "ISOPLANE".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
|
/// 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::CommandLineArrowProbe { .. }
|
||||||
| Message::CommandLineArrowResolved { .. }
|
| Message::CommandLineArrowResolved { .. }
|
||||||
| Message::DynTabNext
|
| Message::DynTabNext
|
||||||
|
| Message::DynTabPrev
|
||||||
| Message::MTextCaretMove(_)
|
| Message::MTextCaretMove(_)
|
||||||
| Message::DeleteSelected
|
| Message::DeleteSelected
|
||||||
| Message::ToggleSnapEnabled
|
| Message::ToggleSnapEnabled
|
||||||
@@ -2156,16 +2157,22 @@ impl OpenCADStudio {
|
|||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
// Capture prompt/options while holding cmd borrow, then release before
|
// Capture prompt/options while holding cmd borrow, then release before
|
||||||
// borrowing command_line to satisfy borrow checker.
|
// 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 let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
|
||||||
if cmd.on_live_input(&live_input) {
|
let changed = cmd.on_live_input(&live_input);
|
||||||
(true, cmd.options(), cmd.prompt())
|
let suggestions = cmd.text_suggestions(&live_input);
|
||||||
|
if changed {
|
||||||
|
(true, cmd.options(), cmd.prompt(), suggestions)
|
||||||
} else {
|
} else {
|
||||||
(false, Vec::new(), String::new())
|
(false, Vec::new(), String::new(), suggestions)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
(false, Vec::new(), String::new())
|
(false, Vec::new(), String::new(), Vec::new())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
self.command_line
|
||||||
|
.set_contextual_suggestions(suggestions);
|
||||||
|
|
||||||
if should_update {
|
if should_update {
|
||||||
self.command_line.set_step_options(opts);
|
self.command_line.set_step_options(opts);
|
||||||
// Update pinned prompt text live without pushing new history entry
|
// Update pinned prompt text live without pushing new history entry
|
||||||
@@ -2194,6 +2201,12 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Message::DynTabNext => {
|
Message::DynTabNext => {
|
||||||
|
if self.command_line.has_contextual_suggestions()
|
||||||
|
&& self.command_line.autocomplete_next()
|
||||||
|
{
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
let n = self.tabs[i].dyn_fields.len();
|
let n = self.tabs[i].dyn_fields.len();
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
@@ -2205,6 +2218,13 @@ impl OpenCADStudio {
|
|||||||
self.focus_cmd_input()
|
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) => {
|
Message::SplitModelViewport(horizontal) => {
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
self.tabs[i].scene.split_active_pane(horizontal);
|
self.tabs[i].scene.split_active_pane(horizontal);
|
||||||
@@ -2241,7 +2261,8 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
if !self.command_line.history_navigation_active()
|
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()
|
&& self.command_line.autocomplete_prev()
|
||||||
{
|
{
|
||||||
return Task::none();
|
return Task::none();
|
||||||
@@ -2267,7 +2288,8 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
if !self.command_line.history_navigation_active()
|
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()
|
&& self.command_line.autocomplete_next()
|
||||||
{
|
{
|
||||||
return Task::none();
|
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.input.clear();
|
||||||
self.command_line.autocomplete_cursor = None;
|
self.command_line.autocomplete_cursor = None;
|
||||||
self.command_line.close_history();
|
self.dispatch_command(&value)
|
||||||
self.dispatch_command(&cmd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Message::CommandOptionPick(kw) => {
|
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
|
// collecting its own input. A pending Lengthen / Radius / Arc Length
|
||||||
// value is numeric input, not the prefix of a new command (for example,
|
// value is numeric input, not the prefix of a new command (for example,
|
||||||
// `3` must not open the 3DALIGN / 3DARRAY suggestions).
|
// `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,
|
// Dynamic input captures keystrokes when its fields are showing,
|
||||||
// so the command-line field must release focus / its on_input.
|
// so the command-line field must release focus / its on_input.
|
||||||
// The MText preview also captures keystrokes (typing edits it), so the
|
// The MText preview also captures keystrokes (typing edits it), so the
|
||||||
|
|||||||
@@ -2430,6 +2430,32 @@ pub struct PointPickContext {
|
|||||||
pub aperture_px: f32,
|
pub aperture_px: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One context-sensitive command-line autocomplete entry.
|
||||||
|
///
|
||||||
|
/// Normal command-name autocomplete only needs `value`. Interactive commands
|
||||||
|
/// may optionally attach CAD display metadata, allowing the shared suggestion
|
||||||
|
/// popup to render richer rows without knowing which command supplied them.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct CommandSuggestion {
|
||||||
|
pub value: String,
|
||||||
|
pub color: Option<codec::types::Color>,
|
||||||
|
pub visible: Option<bool>,
|
||||||
|
pub frozen: Option<bool>,
|
||||||
|
pub locked: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandSuggestion {
|
||||||
|
pub fn plain(value: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
value: value.into(),
|
||||||
|
color: None,
|
||||||
|
visible: None,
|
||||||
|
frozen: None,
|
||||||
|
locked: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub trait CadCommand: Send {
|
pub trait CadCommand: Send {
|
||||||
/// Preserve source appearance for commands that extract existing entities.
|
/// Preserve source appearance for commands that extract existing entities.
|
||||||
fn preserve_commit_style(&self) -> bool {
|
fn preserve_commit_style(&self) -> bool {
|
||||||
@@ -2471,6 +2497,15 @@ pub trait CadCommand: Send {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Context-sensitive autocomplete entries for the command line while this
|
||||||
|
/// command is active. Unlike normal command-name completion these entries
|
||||||
|
/// may be drawing data such as layer, block or style names.
|
||||||
|
///
|
||||||
|
/// Commands that do not provide contextual completion leave this empty.
|
||||||
|
fn text_suggestions(&self, _input: &str) -> Vec<CommandSuggestion> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
/// Push the active coordinate frame in full precision. Geometry commands
|
/// Push the active coordinate frame in full precision. Geometry commands
|
||||||
/// use it for plane-local construction; inquiry and modify commands use it
|
/// use it for plane-local construction; inquiry and modify commands use it
|
||||||
/// for local deltas, angles and transformation axes.
|
/// for local deltas, angles and transformation axes.
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ pub(super) mod make_current;
|
|||||||
pub mod laytrans;
|
pub mod laytrans;
|
||||||
pub mod match_layer;
|
pub mod match_layer;
|
||||||
pub(super) mod panel;
|
pub(super) mod panel;
|
||||||
|
pub mod quick_layer;
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
use crate::command::{CadCommand, CmdResult, CommandSuggestion, InputKind};
|
||||||
|
use codec::Handle;
|
||||||
|
use glam::DVec3;
|
||||||
|
|
||||||
|
/// Fast searchable layer picker.
|
||||||
|
///
|
||||||
|
/// If objects were selected when QLAYER started, the chosen layer is applied
|
||||||
|
/// to those objects. With no selection, the chosen layer becomes current.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct QuickLayerEntry {
|
||||||
|
name: String,
|
||||||
|
color: Option<codec::types::Color>,
|
||||||
|
visible: bool,
|
||||||
|
frozen: bool,
|
||||||
|
locked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QuickLayerEntry {
|
||||||
|
pub fn new(
|
||||||
|
name: String,
|
||||||
|
color: codec::types::Color,
|
||||||
|
visible: bool,
|
||||||
|
frozen: bool,
|
||||||
|
locked: bool,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
color: Some(color),
|
||||||
|
visible,
|
||||||
|
frozen,
|
||||||
|
locked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plain(name: String) -> Self {
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
color: None,
|
||||||
|
visible: true,
|
||||||
|
frozen: false,
|
||||||
|
locked: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct QuickLayerCommand {
|
||||||
|
layers: Vec<QuickLayerEntry>,
|
||||||
|
selected: Vec<Handle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QuickLayerCommand {
|
||||||
|
pub fn new(layers: Vec<String>, selected: Vec<Handle>) -> Self {
|
||||||
|
let entries = layers
|
||||||
|
.into_iter()
|
||||||
|
.map(QuickLayerEntry::plain)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Self::new_with_entries(entries, selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_with_entries(
|
||||||
|
mut layers: Vec<QuickLayerEntry>,
|
||||||
|
selected: Vec<Handle>,
|
||||||
|
) -> Self {
|
||||||
|
layers.sort_by_key(|layer| layer.name.to_lowercase());
|
||||||
|
layers.dedup_by(|a, b| a.name.eq_ignore_ascii_case(&b.name));
|
||||||
|
|
||||||
|
Self { layers, selected }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fuzzy_score(name: &str, input: &str) -> Option<(u8, usize, usize, String)> {
|
||||||
|
let name_lower = name.to_lowercase();
|
||||||
|
let query = input.trim().to_lowercase();
|
||||||
|
|
||||||
|
if query.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact match.
|
||||||
|
if name_lower == query {
|
||||||
|
return Some((0, 0, name_lower.len(), name_lower));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begins with typed text.
|
||||||
|
if name_lower.starts_with(&query) {
|
||||||
|
return Some((1, 0, name_lower.len(), name_lower));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A word inside the layer begins with typed text.
|
||||||
|
for (index, word) in name_lower
|
||||||
|
.split(|c: char| matches!(c, ' ' | '-' | '_' | '.'))
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
if word.starts_with(&query) {
|
||||||
|
return Some((2, index, name_lower.len(), name_lower));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal substring.
|
||||||
|
if let Some(position) = name_lower.find(&query) {
|
||||||
|
return Some((3, position, name_lower.len(), name_lower));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approximate fallback: ordered subsequence.
|
||||||
|
// Example: "cte" can still find "Corte".
|
||||||
|
let chars: Vec<char> = name_lower.chars().collect();
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
let mut gaps = 0usize;
|
||||||
|
|
||||||
|
for wanted in query.chars() {
|
||||||
|
let Some(relative) = chars[cursor..]
|
||||||
|
.iter()
|
||||||
|
.position(|candidate| *candidate == wanted)
|
||||||
|
else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
gaps += relative;
|
||||||
|
cursor += relative + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((4, gaps, name_lower.len(), name_lower))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches(&self, input: &str) -> Vec<String> {
|
||||||
|
let mut matches: Vec<_> = self
|
||||||
|
.layers
|
||||||
|
.iter()
|
||||||
|
.filter_map(|layer| {
|
||||||
|
Self::fuzzy_score(&layer.name, input)
|
||||||
|
.map(|score| (score, layer.name.clone()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
matches.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
|
||||||
|
matches
|
||||||
|
.into_iter()
|
||||||
|
.map(|(_, name)| name)
|
||||||
|
.take(8)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn encode_layer_name(name: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(name.len() * 2);
|
||||||
|
for byte in name.as_bytes() {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
let _ = write!(&mut out, "{byte:02X}");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn decode_layer_name(encoded: &str) -> Option<String> {
|
||||||
|
if encoded.len() % 2 != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut bytes = Vec::with_capacity(encoded.len() / 2);
|
||||||
|
let chars: Vec<char> = encoded.chars().collect();
|
||||||
|
|
||||||
|
for i in (0..chars.len()).step_by(2) {
|
||||||
|
let hi = chars[i].to_digit(16)?;
|
||||||
|
let lo = chars[i + 1].to_digit(16)?;
|
||||||
|
bytes.push(((hi << 4) | lo) as u8);
|
||||||
|
}
|
||||||
|
|
||||||
|
String::from_utf8(bytes).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_layer(&self, input: &str) -> Option<String> {
|
||||||
|
self.layers
|
||||||
|
.iter()
|
||||||
|
.find(|layer| layer.name.eq_ignore_ascii_case(input.trim()))
|
||||||
|
.map(|layer| layer.name.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CadCommand for QuickLayerCommand {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"QLAYER"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt(&self) -> String {
|
||||||
|
if self.selected.is_empty() {
|
||||||
|
crate::t!("QLAYER Search current layer:").into_owned()
|
||||||
|
} else {
|
||||||
|
crate::tf!(
|
||||||
|
"QLAYER Search destination layer for {} object(s):",
|
||||||
|
self.selected.len()
|
||||||
|
)
|
||||||
|
.into_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input_kind(&self) -> InputKind {
|
||||||
|
// Layer names may legitimately contain spaces. Treat the search as one
|
||||||
|
// complete text value so "Vista 1" is not split into "Vista" + "1".
|
||||||
|
InputKind::FreeText
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_suggestions(&self, input: &str) -> Vec<CommandSuggestion> {
|
||||||
|
self.matches(input)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|name| {
|
||||||
|
self.layers
|
||||||
|
.iter()
|
||||||
|
.find(|layer| layer.name == name)
|
||||||
|
.map(|layer| CommandSuggestion {
|
||||||
|
value: layer.name.clone(),
|
||||||
|
color: layer.color.clone(),
|
||||||
|
visible: Some(layer.visible),
|
||||||
|
frozen: Some(layer.frozen),
|
||||||
|
locked: Some(layer.locked),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||||
|
let Some(layer) = self.canonical_layer(text) else {
|
||||||
|
return Some(CmdResult::ReportError(format!(
|
||||||
|
"QLAYER: no layer named \"{}\".",
|
||||||
|
text.trim()
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(CmdResult::Relaunch(
|
||||||
|
format!(
|
||||||
|
"QLAYER_APPLY_HEX {}",
|
||||||
|
Self::encode_layer_name(&layer)
|
||||||
|
),
|
||||||
|
std::mem::take(&mut self.selected),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_point(&mut self, _point: DVec3) -> CmdResult {
|
||||||
|
CmdResult::NeedPoint
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_enter(&mut self) -> CmdResult {
|
||||||
|
CmdResult::NeedPoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inventory::submit!(crate::command::CommandRegistration {
|
||||||
|
names: &["QLAYER"]
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn command() -> QuickLayerCommand {
|
||||||
|
QuickLayerCommand::new(
|
||||||
|
vec![
|
||||||
|
"Corte".into(),
|
||||||
|
"Corte 2".into(),
|
||||||
|
"Curvas de nivel".into(),
|
||||||
|
"E-Proyecciones".into(),
|
||||||
|
"Propiedad".into(),
|
||||||
|
"Proyección".into(),
|
||||||
|
],
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_match_ranks_first() {
|
||||||
|
let matches = command().matches("corte");
|
||||||
|
|
||||||
|
assert_eq!(matches.first().map(String::as_str), Some("Corte"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prefix_search_finds_layer() {
|
||||||
|
let matches = command().matches("pro");
|
||||||
|
|
||||||
|
assert!(matches.iter().any(|name| name == "Proyección"));
|
||||||
|
assert!(matches.iter().any(|name| name == "Propiedad"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn substring_search_finds_layer() {
|
||||||
|
let matches = command().matches("nivel");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
matches.first().map(String::as_str),
|
||||||
|
Some("Curvas de nivel")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn approximate_search_finds_layer() {
|
||||||
|
let matches = command().matches("cte");
|
||||||
|
|
||||||
|
assert!(matches.iter().any(|name| name == "Corte"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layer_name_roundtrip_preserves_spaces() {
|
||||||
|
let original = "Vista 1";
|
||||||
|
let encoded = QuickLayerCommand::encode_layer_name(original);
|
||||||
|
let decoded = QuickLayerCommand::decode_layer_name(&encoded);
|
||||||
|
|
||||||
|
assert_eq!(decoded.as_deref(), Some(original));
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
-9
@@ -168,6 +168,10 @@ pub struct CommandLine {
|
|||||||
/// means the first match is pre-selected (highlighted) before keyboard
|
/// means the first match is pre-selected (highlighted) before keyboard
|
||||||
/// navigation begins. Reset when input changes.
|
/// navigation begins. Reset when input changes.
|
||||||
pub autocomplete_cursor: Option<usize>,
|
pub autocomplete_cursor: Option<usize>,
|
||||||
|
/// Suggestions supplied by the active CAD command. When non-empty these
|
||||||
|
/// replace normal command-name autocomplete, allowing commands to offer
|
||||||
|
/// drawing-specific values such as layers, blocks or styles.
|
||||||
|
pub contextual_suggestions: Vec<crate::command::CommandSuggestion>,
|
||||||
/// Command names contributed by loaded plugins, refreshed whenever the
|
/// Command names contributed by loaded plugins, refreshed whenever the
|
||||||
/// enabled-plugin set changes. Merged into autocomplete alongside the
|
/// enabled-plugin set changes. Merged into autocomplete alongside the
|
||||||
/// compile-time command registry, so runtime plugin commands are typeable
|
/// compile-time command registry, so runtime plugin commands are typeable
|
||||||
@@ -212,6 +216,7 @@ impl Default for CommandLine {
|
|||||||
history_open: false,
|
history_open: false,
|
||||||
history_height: 0.0,
|
history_height: 0.0,
|
||||||
autocomplete_cursor: None,
|
autocomplete_cursor: None,
|
||||||
|
contextual_suggestions: Vec::new(),
|
||||||
dynamic_commands: Vec::new(),
|
dynamic_commands: Vec::new(),
|
||||||
command_aliases: rustc_hash::FxHashMap::default(),
|
command_aliases: rustc_hash::FxHashMap::default(),
|
||||||
step_prompt: None,
|
step_prompt: None,
|
||||||
@@ -607,6 +612,34 @@ impl CommandLine {
|
|||||||
self.step_prompt = None;
|
self.step_prompt = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace the active command's context-sensitive suggestions.
|
||||||
|
/// A new result set always resets keyboard navigation to the first item.
|
||||||
|
pub fn set_contextual_suggestions(
|
||||||
|
&mut self,
|
||||||
|
suggestions: Vec<crate::command::CommandSuggestion>,
|
||||||
|
) {
|
||||||
|
let changed = self
|
||||||
|
.contextual_suggestions
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.value.as_str())
|
||||||
|
.ne(suggestions.iter().map(|entry| entry.value.as_str()));
|
||||||
|
|
||||||
|
self.contextual_suggestions = suggestions;
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
self.autocomplete_cursor = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_contextual_suggestions(&mut self) {
|
||||||
|
self.contextual_suggestions.clear();
|
||||||
|
self.autocomplete_cursor = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_contextual_suggestions(&self) -> bool {
|
||||||
|
!self.contextual_suggestions.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
/// Move the autocomplete highlight up one entry. Wraps to the last match.
|
/// Move the autocomplete highlight up one entry. Wraps to the last match.
|
||||||
pub fn autocomplete_prev(&mut self) -> bool {
|
pub fn autocomplete_prev(&mut self) -> bool {
|
||||||
let len = self.autocomplete_matches().len();
|
let len = self.autocomplete_matches().len();
|
||||||
@@ -648,6 +681,14 @@ impl CommandLine {
|
|||||||
/// Autocomplete suggestions for the current input — see
|
/// Autocomplete suggestions for the current input — see
|
||||||
/// [`ranked_matches`]. Includes loaded plugins' commands (#272).
|
/// [`ranked_matches`]. Includes loaded plugins' commands (#272).
|
||||||
pub fn autocomplete_matches(&self) -> Vec<String> {
|
pub fn autocomplete_matches(&self) -> Vec<String> {
|
||||||
|
if !self.contextual_suggestions.is_empty() {
|
||||||
|
return self
|
||||||
|
.contextual_suggestions
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.value.clone())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
ranked_matches(
|
ranked_matches(
|
||||||
self.input.trim(),
|
self.input.trim(),
|
||||||
&self.dynamic_commands,
|
&self.dynamic_commands,
|
||||||
@@ -826,15 +867,64 @@ impl CommandLine {
|
|||||||
let mut col = column![].spacing(0).width(Length::Fill);
|
let mut col = column![].spacing(0).width(Length::Fill);
|
||||||
for (idx, cmd) in matches.iter().enumerate() {
|
for (idx, cmd) in matches.iter().enumerate() {
|
||||||
let is_selected = idx == cursor;
|
let is_selected = idx == cursor;
|
||||||
// Every row keeps the icon's width so names line up.
|
let details = self.contextual_suggestions.get(idx);
|
||||||
let icon: Element<'_, Message> =
|
|
||||||
match crate::modules::registry::command_icon(cmd) {
|
let mut label = row![].spacing(6).align_y(iced::Center);
|
||||||
Some(bytes) => crate::ui::icons::semantic(bytes, 14.0),
|
|
||||||
None => Space::new().width(14.0).into(),
|
if let Some(details) = details {
|
||||||
};
|
if let Some(visible) = details.visible {
|
||||||
let label = row![icon, text(cmd.clone()).size(11)]
|
label = label.push(crate::ui::icons::semantic(
|
||||||
.spacing(6)
|
crate::ui::icons::layer_visible(visible),
|
||||||
.align_y(iced::Center);
|
14.0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(frozen) = details.frozen {
|
||||||
|
label = label.push(crate::ui::icons::semantic(
|
||||||
|
crate::ui::icons::layer_freeze(frozen),
|
||||||
|
14.0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(locked) = details.locked {
|
||||||
|
label = label.push(crate::ui::icons::semantic(
|
||||||
|
crate::ui::icons::layer_lock(locked),
|
||||||
|
14.0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(color) = details.color.clone() {
|
||||||
|
let swatch_color =
|
||||||
|
crate::ui::window::layers::iced_color_from_acad(&color);
|
||||||
|
|
||||||
|
let swatch = container(text(""))
|
||||||
|
.style(move |theme: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(swatch_color)),
|
||||||
|
border: Border {
|
||||||
|
color: theme.palette().background.strong.color,
|
||||||
|
width: 1.0,
|
||||||
|
radius: 1.0.into(),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.width(12)
|
||||||
|
.height(12);
|
||||||
|
|
||||||
|
label = label.push(swatch);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal command autocomplete keeps its existing command icon.
|
||||||
|
let icon: Element<'_, Message> =
|
||||||
|
match crate::modules::registry::command_icon(cmd) {
|
||||||
|
Some(bytes) => crate::ui::icons::semantic(bytes, 14.0),
|
||||||
|
None => Space::new().width(14.0).into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
label = label.push(icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
label = label.push(text(cmd.clone()).size(11));
|
||||||
|
|
||||||
let row = button(label)
|
let row = button(label)
|
||||||
.on_press(Message::CommandSuggestionPick(cmd.clone()))
|
.on_press(Message::CommandSuggestionPick(cmd.clone()))
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
|
|||||||
Reference in new issue
Block a user