From 63de137106398bde6354c57c6b57644bd9c811e5 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 27 Jun 2026 14:45:51 +0300 Subject: [PATCH] feat(cmdline): Enter runs closest command match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/commands/mod.rs | 28 +++++++++++++- src/app/update/command.rs | 2 +- src/ui/command_line.rs | 79 +++++++++++++++++++++------------------ 3 files changed, 71 insertions(+), 38 deletions(-) diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 7a4efff01..b4c949eaf 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -36,6 +36,22 @@ impl OpenCADStudio { } pub(super) fn dispatch_command(&mut self, cmd: &str) -> Task { + 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 { + self.dispatch_command_inner(cmd, true) + } + + fn dispatch_command_inner(&mut self, cmd: &str, allow_suggest: bool) -> Task { 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) diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 71acc4d64..0c2f34c41 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -338,7 +338,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { 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` diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index 812d5697f..5f59646f8 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -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,