cad-editor/src/ui/command_line.rs
Hakan Seven 77c0c5a9d0 feat(plugin): surface loaded plugin commands in command-line autocomplete
Autocomplete suggestions came only from all_registered_command_names() —
the compile-time inventory registry returning &'static str — so
runtime-loaded plugin commands could never appear. Typing a plugin's
command prefix (e.g. `LS_`) showed nothing even with the plugin loaded
and its ribbon tab active; dispatch worked, only discovery was blind.

Collect each enabled plugin's ribbon ToolDef command ids plus its
manifest command_prefixes into a dynamic candidate pool
(OwnedRibbonGroup::command_ids → PluginManager::command_names →
plugin_command_names), refreshed in rebuild_ribbon_modules on startup
load, settings reload, and every enable/disable toggle. ranked_matches
now merges this pool with the static registry and returns owned strings.

A plugin.toml `commands = [...]` list for sub-verbs with no ribbon button
(e.g. LS_AUTOLABEL) stays a follow-up — it's a plugin-API surface change.

Closes #272

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 23:01:53 +03:00

711 lines
26 KiB
Rust

//! OpenCADStudio-style command line — bottom panel with input and history
use iced::time::Instant;
use crate::app::Message;
use iced::widget::{
button, column, container, opaque, row, text, text_editor, text_input, Space,
};
use iced::{Background, Border, Color, Element, Length, Theme};
pub const CMD_INPUT_ID: &str = "cmd_input";
/// How long a history entry stays visible on the overlay before fading
/// out. Picking the full archive happens through the dropdown button.
const HISTORY_VISIBLE_SECS: f32 = 3.0;
/// How many autocomplete matches the suggestion popup shows at once.
const AUTOCOMPLETE_LIMIT: usize = 8;
fn cmd_input_id() -> iced::widget::Id {
iced::widget::Id::new(CMD_INPUT_ID)
}
const MAX_HISTORY: usize = 64;
#[derive(Clone, Default)]
pub struct CommandLine {
pub input: String,
pub history: Vec<HistoryEntry>,
/// Commands the user has typed (for ↑/↓ recall). Holds the raw typed
/// strings so the line can be re-edited; distinct from `recent_commands`.
pub cmd_recall: Vec<String>,
/// Commands actually dispatched, from any source (command line, ribbon,
/// context menu, shortcuts), newest last. Drives the right-click "Repeat"
/// menu so it reflects every command source, not only typed ones.
pub recent_commands: Vec<String>,
/// Current position in `cmd_recall` while navigating (None = not navigating).
recall_cursor: Option<usize>,
/// Saved draft input before the user started navigating history.
recall_draft: String,
/// When `true`, the dropdown showing the full backlog is open.
pub history_open: bool,
/// Index of the currently-highlighted autocomplete suggestion, or
/// `None` when the user hasn't yet started navigating with the
/// arrow keys. Reset on every keystroke.
pub autocomplete_cursor: Option<usize>,
/// Command names contributed by loaded plugins, refreshed whenever the
/// enabled-plugin set changes. Merged into autocomplete alongside the
/// compile-time command registry, so runtime plugin commands are typeable
/// and discoverable — not only reachable via ribbon buttons (#272).
pub dynamic_commands: Vec<String>,
/// The active command step's prompt, mirrored here so a step change
/// can be detected and the pinned (non-fading) history line updated.
step_prompt: Option<String>,
}
#[derive(Clone, Debug)]
pub struct HistoryEntry {
pub kind: EntryKind,
pub text: String,
/// When this entry was pushed. Used by the overlay to fade entries
/// out after `HISTORY_VISIBLE_SECS`. The dropdown popup ignores it
/// and always shows the whole list.
pub created_at: Instant,
/// The active command step's prompt is pinned so it does not fade
/// while the user is still working on that step. When the step
/// completes the pin is cleared and the normal cooldown resumes.
pub pinned: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EntryKind {
Command,
Output,
Error,
Info,
}
impl CommandLine {
pub fn new() -> Self {
let mut cl = Self::default();
cl.push_info("Open CAD Studio ready.");
cl.push_info("Type a command or use the ribbon. Open OBJ: INSERT tab.");
cl
}
pub fn submit(&mut self) -> Option<String> {
let raw = self.input.trim().to_string();
if raw.is_empty() {
return None;
}
// Uppercase only the command verb (the first token); keep arguments
// verbatim so case-sensitive values survive — file paths on
// case-sensitive filesystems, identifiers, plugin command arguments.
// Dispatch matches verbs in uppercase and each sub-command handler
// re-uppercases its own keywords, so only free-form args are affected.
let cmd = match raw.split_once(char::is_whitespace) {
Some((verb, rest)) => format!("{} {}", verb.to_uppercase(), rest),
None => raw.to_uppercase(),
};
// Record in recall list (avoid duplicates at the top).
if self.cmd_recall.last().map(|s| s.as_str()) != Some(raw.as_str()) {
self.cmd_recall.push(raw);
if self.cmd_recall.len() > 50 {
self.cmd_recall.remove(0);
}
}
self.recall_cursor = None;
self.recall_draft.clear();
self.push_command(&self.input.clone());
self.input.clear();
Some(cmd)
}
/// Record a dispatched command for the right-click "Repeat" menu, skipping
/// a consecutive duplicate and capping the list. Called from the dispatch
/// choke point so commands from every source are captured.
pub fn record_recent(&mut self, cmd: &str) {
let cmd = cmd.trim();
if cmd.is_empty() {
return;
}
if self.recent_commands.last().map(|s| s.as_str()) != Some(cmd) {
self.recent_commands.push(cmd.to_string());
if self.recent_commands.len() > 50 {
self.recent_commands.remove(0);
}
}
}
/// Navigate to the previous command in recall history (↑).
pub fn history_prev(&mut self) {
if self.cmd_recall.is_empty() {
return;
}
let cursor = match self.recall_cursor {
None => {
self.recall_draft = self.input.clone();
self.cmd_recall.len() - 1
}
Some(c) if c > 0 => c - 1,
Some(c) => c,
};
self.recall_cursor = Some(cursor);
self.input = self.cmd_recall[cursor].clone();
}
/// Navigate to the next command in recall history (↓).
pub fn history_next(&mut self) {
match self.recall_cursor {
None => {}
Some(c) if c + 1 < self.cmd_recall.len() => {
let next = c + 1;
self.recall_cursor = Some(next);
self.input = self.cmd_recall[next].clone();
}
Some(_) => {
self.recall_cursor = None;
self.input = self.recall_draft.clone();
}
}
}
pub fn push_command(&mut self, cmd: &str) {
self.push(EntryKind::Command, format!("Command: {cmd}"));
}
pub fn push_output(&mut self, msg: &str) {
self.push(EntryKind::Output, msg.to_string());
}
pub fn push_error(&mut self, msg: &str) {
self.push(EntryKind::Error, format!("*Invalid* {msg}"));
}
pub fn push_info(&mut self, msg: &str) {
self.push(EntryKind::Info, msg.to_string());
}
fn push(&mut self, kind: EntryKind, text: String) {
self.history.push(HistoryEntry {
kind,
text,
created_at: Instant::now(),
pinned: false,
});
if self.history.len() > MAX_HISTORY {
self.history.remove(0);
}
}
/// Mirror the active command step's prompt. While the step is current
/// its history line is pinned so it does not fade; when the step
/// changes (or the command ends, `prompt == None`) the previous line's
/// cooldown restarts so it fades normally from now. The dispatch /
/// step-transition code already pushes the prompt as an `Info` line, so
/// the matching tail entry is pinned in place rather than duplicated.
pub fn set_step_prompt(&mut self, prompt: Option<String>) {
if prompt == self.step_prompt {
return;
}
// Release the previous pin and let its cooldown start now.
for e in self.history.iter_mut().filter(|e| e.pinned) {
e.pinned = false;
e.created_at = Instant::now();
}
if let Some(p) = &prompt {
// Reuse the prompt line dispatch/step-transition just pushed;
// otherwise add it.
if self.history.last().map(|e| &e.text) != Some(p) {
self.push(EntryKind::Info, p.clone());
}
if let Some(last) = self.history.last_mut() {
last.pinned = true;
}
}
self.step_prompt = prompt;
}
/// `true` while at least one history entry is still within the
/// visible window — the host app uses this to drive a low-frequency
/// tick subscription so the overlay re-renders and fades the entry
/// once it expires.
pub fn has_visible_history(&self) -> bool {
self.history
.iter()
.any(|e| e.pinned || e.created_at.elapsed().as_secs_f32() < HISTORY_VISIBLE_SECS)
}
pub fn toggle_history(&mut self) {
self.history_open = !self.history_open;
}
pub fn close_history(&mut self) {
self.history_open = false;
}
/// The whole history flattened to plain text, one entry per line, for the
/// clipboard-copy button (issue #232). Entries carry no per-line prefix
/// beyond what `push_*` already baked into `text` (e.g. "Command: "), so
/// the pasted block reads the same as the on-screen log.
pub fn history_plain_text(&self) -> String {
self.history
.iter()
.map(|e| e.text.as_str())
.collect::<Vec<_>>()
.join("\n")
}
/// Drop every history line. The step-prompt mirror is reset too so a
/// later step still repins correctly.
pub fn clear_history(&mut self) {
self.history.clear();
self.step_prompt = None;
}
/// Move the autocomplete highlight up one entry. Wraps to the last
/// match. Returns `true` when there was a list to navigate.
pub fn autocomplete_prev(&mut self) -> bool {
let len = self.autocomplete_matches().len();
if len == 0 {
return false;
}
// 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
}
/// Move the autocomplete highlight down one entry. Wraps to the
/// first match. Returns `true` when there was a list to navigate.
pub fn autocomplete_next(&mut self) -> bool {
let len = self.autocomplete_matches().len();
if len == 0 {
return false;
}
// 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
}
/// The command name the user has currently highlighted in the
/// autocomplete popup, if any.
pub fn selected_suggestion(&self) -> Option<String> {
let matches = self.autocomplete_matches();
self.autocomplete_cursor
.and_then(|i| matches.get(i).cloned())
}
/// Autocomplete suggestions for the current input — see
/// [`ranked_matches`]. Includes loaded plugins' commands (#272).
pub fn autocomplete_matches(&self) -> Vec<String> {
ranked_matches(self.input.trim(), &self.dynamic_commands)
}
pub fn view<'a>(
&'a self,
show_autocomplete: bool,
dyn_capturing: bool,
history_content: &'a text_editor::Content,
) -> Element<'a, Message> {
// Only the most recent entries pushed within the last few
// seconds show on the overlay. The dropdown button keeps the
// full backlog reachable when the user actually wants it.
let visible: Vec<&HistoryEntry> = self
.history
.iter()
.filter(|e| e.pinned || e.created_at.elapsed().as_secs_f32() < HISTORY_VISIBLE_SECS)
.collect();
let start = visible.len().saturating_sub(4);
let history_rows = visible[start..]
.iter()
.fold(column![].spacing(0), |col, entry| {
let color = match entry.kind {
EntryKind::Command => CMD_COLOR,
EntryKind::Output => OUT_COLOR,
EntryKind::Error => ERR_COLOR,
EntryKind::Info => INFO_COLOR,
};
col.push(container(text(&entry.text).size(11).color(color)).padding([1, 8]))
});
let prompt = container(text("Command:").size(11).color(PROMPT_COLOR)).padding([5, 8]);
// While dynamic input is capturing keystrokes, the command-line
// text field is left without an `on_input` handler so it can't
// grab focus or swallow numeric keys — those flow through the
// global key subscription into the dynamic-input fields instead.
let mut input = text_input("", &self.input).id(cmd_input_id());
if !dyn_capturing {
input = input
.on_input(Message::CommandInput)
.on_submit(Message::CommandSubmit);
}
let input = input
.style(|_: &Theme, _| text_input::Style {
background: Background::Color(INPUT_BG),
border: Border {
color: Color {
r: 0.40,
g: 0.60,
b: 0.90,
a: 1.0,
},
width: 1.0,
radius: 2.0.into(),
},
icon: Color::WHITE,
placeholder: Color {
r: 0.4,
g: 0.4,
b: 0.4,
a: 1.0,
},
value: Color::WHITE,
selection: Color {
r: 0.20,
g: 0.44,
b: 0.72,
a: 0.5,
},
})
.size(11)
.padding([4, 6]);
// Autocomplete suggestions panel, shown above the input row
// when the user has typed a prefix that matches at least one
// command. Each row is a button — clicking it dispatches the
// command directly.
let autocomplete: Element<'_, Message> = if show_autocomplete {
let matches = self.autocomplete_matches();
if matches.is_empty() {
container(column![]).height(0).into()
} else {
// 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 == idx;
let row = button(text(cmd.clone()).size(11).color(CMD_COLOR))
.on_press(Message::CommandSuggestionPick(cmd.clone()))
.width(Length::Fill)
.padding([2, 8])
.style(move |_: &Theme, status| {
let bg = if is_selected {
Color {
r: 0.28,
g: 0.40,
b: 0.56,
a: 1.0,
}
} else if matches!(status, button::Status::Hovered) {
Color {
r: 0.22,
g: 0.30,
b: 0.42,
a: 1.0,
}
} else {
PANEL_BG
};
button::Style {
background: Some(Background::Color(bg)),
text_color: Color::WHITE,
border: Border::default(),
..Default::default()
}
});
col = col.push(row);
}
container(col)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(PANEL_BG)),
border: Border {
color: BORDER_COLOR,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
})
.width(Length::Fill)
.into()
}
} else {
container(column![]).height(0).into()
};
// Dropdown trigger next to the input. Clicking it pops up the
// full backlog (everything pushed since the app started) so the
// user can recover anything that has already faded off the
// overlay.
let dropdown_icon = if self.history_open {
crate::ui::icons::arrow_down(11.0, PROMPT_COLOR)
} else {
crate::ui::icons::arrow_right(11.0, PROMPT_COLOR)
};
let dropdown_btn = button(dropdown_icon)
.on_press(Message::CommandHistoryToggle)
.style(|_: &Theme, _status| button::Style {
background: Some(Background::Color(INPUT_ROW_BG)),
text_color: Color::WHITE,
border: Border {
color: BORDER_COLOR,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
})
.padding([2, 6]);
let input_row = row![prompt, input, dropdown_btn]
.spacing(4)
.align_y(iced::Center);
// Full backlog dropdown — appears ABOVE the input pill when open. The
// whole log is rendered in ONE read-only `text_editor` (backed by
// `history_content`) rather than per-line labels, so a single mouse
// drag selects across lines and Ctrl+C copies the lot — issue #232.
// Edits are dropped in the update handler, keeping it read-only. The
// editor scrolls internally past `max_height`; `opaque` stops its
// mouse-wheel events bubbling to the viewport shader behind it (else
// scrolling the history zoomed the drawing).
let dropdown: Element<'a, Message> = if self.history_open {
let log = text_editor(history_content)
.on_action(Message::CommandHistoryEdit)
.size(11)
.padding([2, 8])
.max_height(180.0)
.style(|_: &Theme, _status| text_editor::Style {
background: Background::Color(PANEL_BG),
border: Border::default(),
placeholder: OUT_COLOR,
value: CMD_COLOR,
selection: Color {
r: 0.20,
g: 0.44,
b: 0.72,
a: 0.5,
},
});
// Header strip: a Copy-all and a Clear button pinned above the log.
let copy_btn = button(
row![
crate::ui::icons::tinted(crate::ui::icons::COPY, 11.0, PROMPT_COLOR),
text("Copy").size(11).color(CMD_COLOR),
]
.spacing(4)
.align_y(iced::Center),
)
.on_press(Message::CommandHistoryCopy)
.style(header_btn_style)
.padding([2, 6]);
let clear_btn = button(
row![
crate::ui::icons::tinted(crate::ui::icons::TRASH, 11.0, ERR_COLOR),
text("Clear").size(11).color(CMD_COLOR),
]
.spacing(4)
.align_y(iced::Center),
)
.on_press(Message::CommandHistoryClear)
.style(header_btn_style)
.padding([2, 6]);
let header = container(
row![Space::new().width(Length::Fill), copy_btn, clear_btn]
.spacing(6)
.align_y(iced::Center),
)
.width(Length::Fill)
.padding([2, 6]);
let panel = container(column![header, log])
.style(|_: &Theme| container::Style {
background: Some(Background::Color(PANEL_BG)),
border: Border {
color: BORDER_COLOR,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
})
.width(Length::Fill)
.padding([4, 0]);
opaque(panel).into()
} else {
container(column![]).height(0).into()
};
container(column![
autocomplete,
dropdown,
container(history_rows)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(HISTORY_BG)),
..Default::default()
})
.width(Length::Fill)
.padding([2, 0]),
container(input_row)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(INPUT_ROW_BG)),
border: Border {
color: BORDER_COLOR,
width: 1.0,
radius: 3.0.into()
},
..Default::default()
})
.width(Length::Fill)
// Match the drawing tab bar / status bar height, and vertically
// centre the prompt/input/dropdown within it (issue #216).
.center_y(Length::Fixed(30.0)),
])
.style(|_: &Theme| container::Style {
background: Some(Background::Color(PANEL_BG)),
border: Border {
color: BORDER_COLOR,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
})
.width(Length::Fixed(720.0))
.into()
}
}
/// 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()` — the
/// compile-time `inventory` registry — merged with `dynamic`, the command names
/// contributed by loaded plugins (runtime, so they can't be `&'static`; see
/// #272). Returns owned strings to carry both sources.
pub fn ranked_matches(needle: &str, dynamic: &[String]) -> Vec<String> {
let needle = needle.trim().to_uppercase();
if needle.is_empty() {
return Vec::new();
}
let mut matches: Vec<String> = crate::command::all_registered_command_names()
.into_iter()
.map(|cmd| cmd.to_string())
// Plugin names are uppercased to match the built-ins and the needle,
// so ranking and display stay consistent across both sources.
.chain(dynamic.iter().map(|cmd| cmd.to_uppercase()))
.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(|a, b| {
(!a.starts_with(&needle))
.cmp(&!b.starts_with(&needle))
.then_with(|| a.cmp(b))
});
matches.truncate(AUTOCOMPLETE_LIMIT);
matches
}
/// Flat button style for the history dropdown's Copy / Clear strip: a subtle
/// filled pill that brightens on hover.
fn header_btn_style(_: &Theme, status: button::Status) -> button::Style {
let bg = if matches!(status, button::Status::Hovered) {
Color {
r: 0.24,
g: 0.24,
b: 0.24,
a: 1.0,
}
} else {
INPUT_ROW_BG
};
button::Style {
background: Some(Background::Color(bg)),
text_color: Color::WHITE,
border: Border {
color: BORDER_COLOR,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
}
}
const PANEL_BG: Color = Color {
r: 0.15,
g: 0.15,
b: 0.15,
a: 1.0,
};
const HISTORY_BG: Color = Color {
r: 0.15,
g: 0.15,
b: 0.15,
a: 1.0,
};
const INPUT_ROW_BG: Color = Color {
r: 0.18,
g: 0.18,
b: 0.18,
a: 1.0,
};
const INPUT_BG: Color = Color {
r: 0.12,
g: 0.12,
b: 0.12,
a: 1.0,
};
const BORDER_COLOR: Color = Color {
r: 0.30,
g: 0.30,
b: 0.30,
a: 1.0,
};
const PROMPT_COLOR: Color = Color {
r: 0.55,
g: 0.78,
b: 0.55,
a: 1.0,
};
const CMD_COLOR: Color = Color {
r: 0.80,
g: 0.80,
b: 0.80,
a: 1.0,
};
const OUT_COLOR: Color = Color {
r: 0.65,
g: 0.65,
b: 0.65,
a: 1.0,
};
const ERR_COLOR: Color = Color {
r: 0.90,
g: 0.35,
b: 0.35,
a: 1.0,
};
const INFO_COLOR: Color = Color {
r: 0.50,
g: 0.70,
b: 0.90,
a: 1.0,
};
#[cfg(test)]
mod tests {
use super::ranked_matches;
#[test]
fn dynamic_plugin_commands_surface_in_autocomplete() {
// No built-in command contains the plugin prefix, so an empty pool
// yields nothing — reproducing the #272 bug's blind autocomplete.
assert!(ranked_matches("LS_", &[]).is_empty());
// A loaded plugin's commands become typeable, case-insensitively, and
// the prefix substring surfaces every one of them.
let pool = vec!["LS_LABEL".to_string(), "ls_autolabel".to_string()];
let m = ranked_matches("LS_", &pool);
assert!(m.contains(&"LS_LABEL".to_string()), "got {m:?}");
assert!(m.contains(&"LS_AUTOLABEL".to_string()), "got {m:?}");
}
#[test]
fn builtin_commands_still_match_with_an_empty_pool() {
let m = ranked_matches("LINE", &[]);
assert!(m.iter().any(|c| c == "LINE"), "got {m:?}");
}
}