feat(cmdline): Enter runs closest command match

Typing a partial command (e.g. `bac`) and pressing Enter with no active
command ran into an "Unknown command" error. Now the top autocomplete
match is highlighted and Enter runs it (`BACKGROUND`), matching standard
DWG command-line behavior.

The fallback only fires when no command family matches the typed verb, so
complete aliases that resolve through a dispatch arm but are absent from
the autocomplete registry (`LT`, `ZO`, …) still run as typed. Programmatic
dispatch (ribbon, plugins, headless automation) is unaffected.

Closes #198

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-27 14:45:51 +03:00
commit 63de137106
3 changed files with 71 additions and 38 deletions

View file

@ -36,6 +36,22 @@ impl OpenCADStudio {
}
pub(super) fn dispatch_command(&mut self, cmd: &str) -> Task<Message> {
self.dispatch_command_inner(cmd, false)
}
/// Dispatch a verb typed at the interactive command line, falling back to
/// the closest autocomplete suggestion when the verb matches no command
/// family. Lets a partial command run on Enter (`BAC` → `BACKGROUND`),
/// the standard DWG command-line behavior. The fallback only fires for
/// genuinely unknown verbs, so complete aliases that resolve through a
/// dispatch family (`LT`, `ZO`, …) still run as typed. Programmatic
/// callers (ribbon, plugins, headless automation) use `dispatch_command`
/// and never get silent substitution.
pub(super) fn dispatch_command_or_suggest(&mut self, cmd: &str) -> Task<Message> {
self.dispatch_command_inner(cmd, true)
}
fn dispatch_command_inner(&mut self, cmd: &str, allow_suggest: bool) -> Task<Message> {
let i = self.active_tab;
// Starting a command closes any open ribbon dropdown (e.g. a style
// combo left open) so it does not stay stuck behind the new tool.
@ -126,7 +142,17 @@ impl OpenCADStudio {
return t;
}
// No family matched.
// No family matched. From the interactive command line, run the
// closest autocomplete suggestion instead of erroring, so a partial
// command completes on Enter (`BAC` → `BACKGROUND`). The verb's own
// input was already cleared, so rank against `cmd` directly.
if allow_suggest {
if let Some(top) = crate::ui::command_line::ranked_matches(cmd).first().copied() {
if !top.eq_ignore_ascii_case(cmd) {
return self.dispatch_command_inner(top, false);
}
}
}
self.command_line
.push_error(&format!("Unknown command: {cmd}"));
self.finish_dispatch(cmd)

View file

@ -338,7 +338,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
return self.focus_cmd_input();
}
if let Some(cmd) = self.command_line.submit() {
return self.dispatch_command(&cmd);
return self.dispatch_command_or_suggest(&cmd);
}
// Empty Enter / Space with no active command repeats the
// last dispatched command — same shortcut `CommandFinalize`

View file

@ -210,11 +210,10 @@ impl CommandLine {
if len == 0 {
return false;
}
let next = match self.autocomplete_cursor {
None => len - 1,
Some(0) => len - 1,
Some(i) => i - 1,
};
// No explicit cursor means the top match (index 0) is highlighted,
// so ↑ from there wraps to the last entry.
let cur = self.autocomplete_cursor.unwrap_or(0);
let next = if cur == 0 { len - 1 } else { cur - 1 };
self.autocomplete_cursor = Some(next);
true
}
@ -226,11 +225,10 @@ impl CommandLine {
if len == 0 {
return false;
}
let next = match self.autocomplete_cursor {
None => 0,
Some(i) if i + 1 < len => i + 1,
Some(_) => 0,
};
// No explicit cursor means the top match (index 0) is highlighted,
// so ↓ from there advances to the next entry (wrapping at the end).
let cur = self.autocomplete_cursor.unwrap_or(0);
let next = if cur + 1 < len { cur + 1 } else { 0 };
self.autocomplete_cursor = Some(next);
true
}
@ -243,31 +241,10 @@ impl CommandLine {
.and_then(|i| matches.get(i).copied())
}
/// Autocomplete suggestions for the current input, capped at
/// [`AUTOCOMPLETE_LIMIT`]. Match is substring-anywhere (typing
/// `LEADER` surfaces `LEADER` and `MLEADER` and `QLEADER`); the
/// list is sorted so prefix matches come first.
///
/// Names come from `crate::command::all_registered_command_names()`
/// which collects every `inventory::submit!` block placed next to a
/// `CadCommand` impl — no central list to maintain.
/// Autocomplete suggestions for the current input — see
/// [`ranked_matches`].
pub fn autocomplete_matches(&self) -> Vec<&'static str> {
let typed = self.input.trim();
if typed.is_empty() {
return Vec::new();
}
let needle = typed.to_uppercase();
let mut matches: Vec<&'static str> = crate::command::all_registered_command_names()
.into_iter()
.filter(|cmd| cmd.contains(&needle))
.collect();
matches.sort();
matches.dedup();
// Prefix matches rank above mid-string ones, then alphabetical
// so the order is stable as the user keeps typing.
matches.sort_by_key(|cmd| (!cmd.starts_with(&needle), *cmd));
matches.truncate(AUTOCOMPLETE_LIMIT);
matches
ranked_matches(self.input.trim())
}
pub fn view(&self, show_autocomplete: bool, dyn_capturing: bool) -> Element<'_, Message> {
@ -341,10 +318,13 @@ impl CommandLine {
if matches.is_empty() {
container(column![]).height(0).into()
} else {
let cursor = self.autocomplete_cursor;
// Before any arrow-key navigation, the top match is
// highlighted so it's visible that Enter runs it — the
// standard DWG command-line behavior.
let cursor = self.autocomplete_cursor.unwrap_or(0);
let mut col = column![].spacing(0).width(Length::Fill);
for (idx, cmd) in matches.iter().enumerate() {
let is_selected = cursor == Some(idx);
let is_selected = cursor == idx;
let row = button(text(*cmd).size(11).color(CMD_COLOR))
.on_press(Message::CommandSuggestionPick(cmd.to_string()))
.width(Length::Fill)
@ -496,6 +476,33 @@ impl CommandLine {
}
}
/// Command names matching `needle`, ranked for autocomplete: case-insensitive
/// substring match (typing `LEADER` surfaces `LEADER`, `MLEADER`, `QLEADER`),
/// prefix matches first, then alphabetical, capped at [`AUTOCOMPLETE_LIMIT`].
/// Shared by the suggestion popup and the Enter-key closest-match fallback so
/// both agree on the top suggestion.
///
/// Names come from `crate::command::all_registered_command_names()` which
/// collects every `inventory::submit!` block placed next to a `CadCommand`
/// impl — no central list to maintain.
pub fn ranked_matches(needle: &str) -> Vec<&'static str> {
let needle = needle.trim().to_uppercase();
if needle.is_empty() {
return Vec::new();
}
let mut matches: Vec<&'static str> = crate::command::all_registered_command_names()
.into_iter()
.filter(|cmd| cmd.contains(&needle))
.collect();
matches.sort();
matches.dedup();
// Prefix matches rank above mid-string ones, then alphabetical so the
// order is stable as the user keeps typing.
matches.sort_by_key(|cmd| (!cmd.starts_with(&needle), *cmd));
matches.truncate(AUTOCOMPLETE_LIMIT);
matches
}
const PANEL_BG: Color = Color {
r: 0.15,
g: 0.15,