Files
OneCAD-Draft/src/ui/command_line.rs
T

1313 lines
51 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! OpenCADStudio-style command line — bottom panel with input and history
use iced::time::Instant;
use crate::app::Message;
use crate::command::CmdOption;
use crate::t;
use iced::widget::{
button, column, container, opaque, row, rule, scrollable, stack, text, text_editor, text_input,
tooltip, Space,
};
use iced::{Background, Border, Color, Element, Length, Padding, Theme};
use std::ops::Range;
use crate::ui::style::common::accessible_accent_threshold;
pub const CMD_INPUT_ID: &str = "cmd_input";
pub const HISTORY_SCROLL_ID: &str = "command_history_scroll";
/// How long a history entry stays visible on the overlay before fading
/// out. Picking the full archive happens through the dropdown button.
/// Default for `COMMANDLINEFADETIME` (ms); the live value lives on
/// [`CommandLine::fade_ms`] so it can be changed via SETVAR.
pub const DEFAULT_COMMANDLINE_FADE_MS: u32 = 3000;
/// COMMANDLINEFADETIME bounds in milliseconds: 0 skips the overlay entirely,
/// 60000 keeps lines for a full minute.
pub const COMMANDLINE_FADE_MIN_MS: u32 = 0;
pub const COMMANDLINE_FADE_MAX_MS: u32 = 60000;
/// Resizable full-history editor bounds. The live upper bound also follows the
/// window height so the command input and a useful drawing area remain visible.
pub const HISTORY_HEIGHT_MIN: f32 = 72.0;
pub const HISTORY_HEIGHT_DEFAULT: f32 = 180.0;
pub const HISTORY_HEIGHT_MAX: f32 = 560.0;
pub fn history_max_height(window_height: f32) -> f32 {
if window_height.is_finite() {
(window_height * 0.60).clamp(HISTORY_HEIGHT_MIN, HISTORY_HEIGHT_MAX)
} else {
HISTORY_HEIGHT_DEFAULT
}
}
/// 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)
}
fn mcp_status(enabled: bool, busy: bool) -> (&'static str, Color) {
if !enabled {
(
"MCP control is off",
Color::from_rgb(0.90, 0.35, 0.35),
)
} else if busy {
(
"MCP is handling a request",
Color::from_rgb(0.95, 0.72, 0.25),
)
} else {
(
"MCP control is ready",
Color::from_rgb(0.35, 0.85, 0.55),
)
}
}
/// Drop a prompt's "[A / B / …]" option listing — used for the pinned line
/// when the same options render as clickable buttons beside it. Collapses the
/// leftover double spaces and the orphaned " :" the removal leaves behind.
fn strip_option_listing(s: &str) -> String {
let Some(a) = s.find('[') else {
return s.to_string();
};
let Some(off) = s[a..].find(']') else {
return s.to_string();
};
let mut out = format!("{}{}", &s[..a], &s[a + off + 1..]);
while out.contains(" ") {
out = out.replace(" ", " ");
}
out.replace(" :", ":").trim().to_string()
}
// Font swaps must preserve U+276F, U+24D8, and U+2715.
const COMMAND_PREFIX: &str = "❯ ";
const INFO_PREFIX: &str = "ⓘ ";
const ERROR_PREFIX: &str = "✕ ";
fn format_info(msg: &str) -> String {
if msg.starts_with(INFO_PREFIX) {
msg.to_string()
} else {
format!("{INFO_PREFIX}{msg}")
}
}
fn format_error(msg: &str) -> String {
format!("{ERROR_PREFIX}{}: {msg}", t!("Invalid").to_uppercase())
}
const MAX_HISTORY: usize = 64;
#[derive(Clone)]
pub struct CommandLine {
pub input: String,
/// Persistent literal-space mode (the `>` toggle button): while on, every
/// line behaves as if it started with `>` — Space stays in the input
/// instead of submitting. Saved in the user config.
pub literal_spaces: bool,
pub history: Vec<HistoryEntry>,
pub error_revision: u64,
pub last_error: Option<String>,
/// Successfully dispatched commands used for ↑/↓ recall, newest last.
/// Stored separately because recall also maintains its own cursor and draft.
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,
/// Persisted height of the full-history editor in logical pixels.
pub history_height: f32,
/// Index of the currently-highlighted autocomplete suggestion, or
/// `None` before keyboard navigation begins. Reset when input changes.
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>,
/// Command aliases (uppercase `alias → command`), a copy of the app's alias
/// table kept here for autocomplete. Aliases are hidden from suggestions
/// (a terse `CC` never appears) while their target command (`COPYCLIP`)
/// still does; and when the whole input is an alias, its target is surfaced
/// as the top suggestion so the highlight matches what Enter runs. (#288)
pub command_aliases: rustc_hash::FxHashMap<String, 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>,
/// The active command step's clickable options, mirrored here so they
/// render as buttons above the input row. Empty when no command is
/// active or the current step offers no options. (#304)
step_options: Vec<CmdOption>,
/// CLIPROMPTLINES: how many temporary prompt lines for a single command
/// are displayed above the command window (0–50, default 3).
cliprompt_lines: u8,
/// COMMANDLINEFADETIME: how long overlay history lines stay visible,
/// in milliseconds (0–60000, default 3000). 0 skips drawing transient
/// lines entirely; the pinned step prompt still shows.
fade_ms: u32,
}
impl Default for CommandLine {
fn default() -> Self {
Self {
input: String::new(),
literal_spaces: false,
history: Vec::new(),
error_revision: 0,
last_error: None,
cmd_recall: Vec::new(),
recent_commands: Vec::new(),
recall_cursor: None,
recall_draft: String::new(),
history_open: false,
history_height: 0.0,
autocomplete_cursor: None,
dynamic_commands: Vec::new(),
command_aliases: rustc_hash::FxHashMap::default(),
step_prompt: None,
step_options: Vec::new(),
cliprompt_lines: 3,
fade_ms: DEFAULT_COMMANDLINE_FADE_MS,
}
}
}
#[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 `fade_ms` (`COMMANDLINEFADETIME`). 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,
}
/// Per-logical-line entry kinds for the selectable full-history editor.
/// Keeping this metadata beside the editor's plain text preserves drag
/// selection and copy while allowing the renderer to restore each entry's
/// original color.
#[derive(Clone, Debug, PartialEq, Eq)]
struct HistoryHighlightSettings {
line_kinds: Vec<EntryKind>,
}
#[derive(Debug)]
struct HistoryHighlighter {
settings: HistoryHighlightSettings,
current_line: usize,
}
impl iced::advanced::text::Highlighter for HistoryHighlighter {
type Settings = HistoryHighlightSettings;
type Highlight = EntryKind;
type Iterator<'a> = std::option::IntoIter<(Range<usize>, EntryKind)>;
fn new(settings: &Self::Settings) -> Self {
Self {
settings: settings.clone(),
current_line: 0,
}
}
fn update(&mut self, settings: &Self::Settings) {
self.settings = settings.clone();
self.current_line = 0;
}
fn change_line(&mut self, line: usize) {
self.current_line = line;
}
fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> {
let kind = self
.settings
.line_kinds
.get(self.current_line)
.cloned()
.unwrap_or(EntryKind::Output);
self.current_line += 1;
Some((0..line.len(), kind)).into_iter()
}
fn current_line(&self) -> usize {
self.current_line
}
}
impl CommandLine {
pub fn new() -> Self {
let mut cl = Self::default();
cl.history_height = HISTORY_HEIGHT_DEFAULT;
cl.push_info(&crate::tr!("command-line", "ready"));
cl.push_info(&crate::tr!("command-line", "hint"));
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(),
};
self.push_command(&self.input.clone());
self.input.clear();
Some(cmd)
}
/// Record a successfully dispatched command for both the right-click
/// "Repeat" menu and ↑/↓ recall. Consecutive duplicates are skipped and
/// both lists are capped. 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);
}
}
if self.cmd_recall.last().map(String::as_str) != Some(cmd) {
self.cmd_recall.push(cmd.to_string());
if self.cmd_recall.len() > 50 {
self.cmd_recall.remove(0);
}
}
self.cancel_history_navigation();
}
pub fn history_navigation_active(&self) -> bool {
self.recall_cursor.is_some()
}
pub fn cancel_history_navigation(&mut self) {
self.recall_cursor = None;
self.recall_draft.clear();
}
/// 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_PREFIX}{} {cmd}", t!("Command:")),
);
}
pub fn push_output(&mut self, msg: &str) {
self.push(EntryKind::Output, msg.to_string());
}
pub fn push_error(&mut self, msg: &str) {
self.error_revision = self.error_revision.wrapping_add(1);
self.last_error = Some(msg.to_owned());
self.push(EntryKind::Error, format_error(msg));
}
/// Append an error unless it is already the latest history line. Repeated
/// retry failures should refresh the concise message, not flood history
/// with identical copies (#498).
#[cfg(not(target_arch = "wasm32"))]
pub fn push_error_once(&mut self, msg: &str) {
self.error_revision = self.error_revision.wrapping_add(1);
self.last_error = Some(msg.to_owned());
let text = format_error(msg);
if let Some(last) = self
.history
.last_mut()
.filter(|entry| entry.kind == EntryKind::Error && entry.text == text)
{
last.created_at = Instant::now();
return;
}
self.push(EntryKind::Error, text);
}
/// Append a warning about the session that no command caused (the
/// renderer fell back to the CPU, say). Styled like an error so it is
/// seen, but it must not touch `last_error` / `error_revision`:
/// automation reads those to decide whether the command it just ran
/// failed, and a warning is not a failed command.
pub fn push_warning(&mut self, msg: &str) {
self.push(EntryKind::Error, format!("*{}* {msg}", t!("Warning")));
}
pub fn push_info(&mut self, msg: &str) {
self.push(EntryKind::Info, format_info(msg));
}
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 {
let formatted = format_info(p);
// Reuse the prompt line dispatch/step-transition just pushed;
// otherwise add it.
if self.history.last().map(|e| &e.text) != Some(&formatted) {
self.push(EntryKind::Info, formatted);
}
if let Some(last) = self.history.last_mut() {
last.pinned = true;
}
}
self.step_prompt = prompt;
}
/// Mirror the active command step's clickable options so `view` can render
/// them as buttons. Called each frame alongside [`Self::set_step_prompt`].
pub fn set_step_options(&mut self, opts: Vec<CmdOption>) {
self.step_options = opts;
}
pub fn set_cliprompt_lines(&mut self, n: u8) {
self.cliprompt_lines = n.min(50);
}
/// COMMANDLINEFADETIME mirror (ms, 0–60000). `0` skips transient overlay
/// lines entirely; the pinned step prompt still shows.
pub fn set_commandline_fade_ms(&mut self, ms: u32) {
self.fade_ms = ms.min(COMMANDLINE_FADE_MAX_MS);
}
pub fn commandline_fade_ms(&self) -> u32 {
self.fade_ms
}
fn fade_secs(&self) -> f32 {
self.fade_ms as f32 / 1000.0
}
fn entry_visible(&self, e: &HistoryEntry) -> bool {
e.pinned || (self.fade_ms > 0 && e.created_at.elapsed().as_secs_f32() < self.fade_secs())
}
/// Visible overlay count respecting CLIPROMPTLINES (0–50). Used in tests.
#[cfg(test)]
pub fn visible_history_count(&self) -> usize {
if self.cliprompt_lines == 0 {
return 0;
}
let visible: Vec<&HistoryEntry> =
self.history.iter().filter(|e| self.entry_visible(e)).collect();
visible.len().min(self.cliprompt_lines as usize)
}
/// `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 {
if self.cliprompt_lines == 0 {
return false;
}
self.history.iter().any(|e| self.entry_visible(e))
}
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")
}
fn history_highlight_settings(&self) -> HistoryHighlightSettings {
let line_kinds = self
.history
.iter()
.flat_map(|entry| {
std::iter::repeat(entry.kind.clone()).take(entry.text.split('\n').count())
})
.collect();
HistoryHighlightSettings { line_kinds }
}
/// 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.
pub fn autocomplete_prev(&mut self) -> bool {
let len = self.autocomplete_matches().len();
if len == 0 {
return false;
}
let current = self.autocomplete_cursor.unwrap_or(0).min(len - 1);
self.autocomplete_cursor = Some(if current == 0 { len - 1 } else { current - 1 });
true
}
/// Move the autocomplete highlight down one entry. Wraps to the first match.
pub fn autocomplete_next(&mut self) -> bool {
let len = self.autocomplete_matches().len();
if len == 0 {
return false;
}
let current = self.autocomplete_cursor.unwrap_or(0).min(len - 1);
self.autocomplete_cursor = Some(if current + 1 < len { current + 1 } else { 0 });
true
}
/// The command name explicitly highlighted in the autocomplete popup.
pub fn selected_suggestion(&self) -> Option<String> {
let matches = self.autocomplete_matches();
self.autocomplete_cursor
.and_then(|index| matches.get(index).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,
&self.command_aliases,
)
}
pub fn view<'a>(
&'a self,
show_autocomplete: bool,
dyn_capturing: bool,
history_content: &'a text_editor::Content,
window_height: f32,
control_enabled: bool,
control_busy: bool,
) -> Element<'a, Message> {
// Only the most recent entries pushed within COMMANDLINEFADETIME
// show on the overlay (0 skips transient lines). The dropdown button
// keeps the full backlog reachable when the user actually wants it.
let mut visible: Vec<&HistoryEntry> =
self.history.iter().filter(|e| self.entry_visible(e)).collect();
// Keep the active prompt/options immediately above the input. Commands
// may emit informational lines while waiting for the next option; those
// lines belong above the pinned interaction row, not below it.
if let Some(index) = visible.iter().position(|entry| entry.pinned) {
let pinned = visible.remove(index);
visible.push(pinned);
}
let clip = self.cliprompt_lines as usize;
// CLIPROMPTLINES == 0 means no temporary prompt lines above command window.
if clip == 0 {
visible.clear();
}
let start = visible.len().saturating_sub(clip);
let has_recent_history = start < visible.len();
let history_rows = visible[start..]
.iter()
.fold(column![].spacing(0), |col, entry| {
let kind = entry.kind.clone();
let is_error = kind == EntryKind::Error;
let entry_text = |value: String| {
let mut txt = text(value).size(11).style({
let kind = kind.clone();
move |theme: &Theme| iced::widget::text::Style {
color: Some(history_color(theme, &kind)),
}
});
if is_error {
txt = txt.font(iced::Font::DEFAULT.weight(iced::font::Weight::Bold));
}
txt
};
// The current step's prompt is the single pinned line. When the
// step offers options, render them as clickable buttons inline
// beside the prompt text — so options need never be typed and
// don't clutter the prompt string itself. (#304) The buttons
// already name every choice, so the prompt's own "[A / B / …]"
// listing is dropped here (the history log keeps the full text).
if entry.pinned && !self.step_options.is_empty() {
let shown = strip_option_listing(&entry.text);
let mut r = row![entry_text(shown)].spacing(6).align_y(iced::Center);
for opt in &self.step_options {
let btn = button(text(opt.label.to_uppercase()).size(11))
.on_press(Message::CommandOptionPick(opt.keyword.clone()))
.padding([1, 6])
.style(|theme: &Theme, status| {
let palette = theme.palette();
let pair = if matches!(
status,
button::Status::Hovered | button::Status::Pressed
) {
palette.primary.weak
} else {
palette.background.weakest
};
button::Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
}
});
r = r.push(btn);
}
col.push(container(r).padding([1, 8]))
} else {
col.push(container(entry_text(entry.text.clone())).padding([1, 8]))
}
});
let prompt = container(text(crate::tr!("command-line", "label")).size(11).style(
|theme: &Theme| {
let p = theme.palette();
let color = accessible_accent_threshold(
p.success.base.color,
p.background.base.color,
p.background.base.text,
4.5,
);
iced::widget::text::Style {
color: Some(color),
}
},
))
.padding([5, 8]);
// Literal-space toggle: while active, every line behaves as if it
// started with `>` — Space stays in the line instead of submitting, so
// arguments with spaces (text strings, paths, `UCS Z 90` as one line)
// can be typed. Persists until toggled off; a hand-typed leading `>`
// lights the button too (same mode, one line only).
let literal_active = self.literal_spaces || self.input.starts_with('>');
let literal_btn = button(text(">").size(11))
.on_press(Message::CommandLiteralToggle)
.padding([2, 6])
.style(move |theme: &Theme, status| {
let palette = theme.palette();
let pair = if literal_active {
palette.primary.weak
} else if matches!(status, button::Status::Hovered | button::Status::Pressed) {
palette.background.weak
} else {
palette.background.weakest
};
button::Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
}
});
let literal_tip = container(text(crate::tr!("command-line", "literal-spaces")).size(11))
.padding([3, 6])
.style(container::bordered_box);
let literal_btn = tooltip(literal_btn, literal_tip, tooltip::Position::Top).gap(4);
// 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.size(11).padding(Padding {
top: 4.0,
right: 30.0,
bottom: 4.0,
left: 6.0,
});
// 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 {
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 = idx == cursor;
let row = button(text(cmd.clone()).size(11))
.on_press(Message::CommandSuggestionPick(cmd.clone()))
.width(Length::Fill)
.padding([2, 8])
.style(move |theme: &Theme, status| {
let palette = theme.palette();
let pair = if is_selected {
palette.primary.weak
} else if matches!(
status,
button::Status::Hovered | button::Status::Pressed
) {
palette.background.weak
} else {
palette.background.base
};
button::Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
border: Border::default(),
..Default::default()
}
});
col = col.push(row);
}
container(col)
.style(container::bordered_box)
.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::themed_arrow_down(11.0)
} else {
crate::ui::icons::themed_arrow_right(11.0)
};
let dropdown_btn = button(dropdown_icon)
.on_press(Message::CommandHistoryToggle)
.style(button::text)
.padding([4, 8]);
let input_with_history = stack![
input,
container(dropdown_btn)
.width(Length::Fill)
.height(Length::Fill)
.align_x(iced::alignment::Horizontal::Right)
.align_y(iced::alignment::Vertical::Center),
]
.width(Length::Fill);
let (mcp_tooltip, mcp_color) = mcp_status(control_enabled, control_busy);
let mcp_btn = button(text("MCP").size(11))
.on_press(Message::ControlToggle)
.style(move |theme: &Theme, status| {
let mut style = button::subtle(theme, status);
style.background = Some(Background::Color(mcp_color));
style.text_color = Color::from_rgb(0.08, 0.08, 0.10);
style.border.color = mcp_color;
style.border.width = 1.0;
style
})
.padding([2, 6]);
let mcp_tip = container(text(t!(mcp_tooltip)).size(11))
.padding([3, 6])
.style(container::bordered_box);
let mcp_btn = container(tooltip(mcp_btn, mcp_tip, tooltip::Position::Top).gap(4))
.padding(Padding {
top: 0.0,
right: 6.0,
bottom: 0.0,
left: 0.0,
});
let input_row = row![prompt, literal_btn, input_with_history, mcp_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. A
// surrounding scrollable supplies the visible scrollbar while
// `opaque` stops its mouse-wheel events reaching the drawing behind it.
let dropdown: Element<'a, Message> = if self.history_open {
let history_height = self
.history_height
.clamp(HISTORY_HEIGHT_MIN, history_max_height(window_height));
let log = text_editor(history_content)
.on_action(Message::CommandHistoryEdit)
.size(11)
.padding([2, 8])
.height(Length::Shrink)
.highlight_with::<HistoryHighlighter>(
self.history_highlight_settings(),
history_highlight_format,
)
.style(|theme: &Theme, _status| {
let palette = theme.palette();
text_editor::Style {
background: Background::Color(palette.background.base.color),
border: Border::default(),
placeholder: palette.background.base.text.scale_alpha(0.72),
value: palette.background.base.text,
selection: palette.primary.base.color.scale_alpha(0.5),
}
});
let log = scrollable(log)
.id(iced::widget::Id::new(HISTORY_SCROLL_ID))
.height(Length::Fixed(history_height))
.direction(scrollable::Direction::Vertical(
scrollable::Scrollbar::new().width(8).scroller_width(6),
))
.anchor_bottom();
// Header strip: a Copy-all and a Clear button pinned above the log.
let copy_btn = button(
row![
crate::ui::icons::themed_success(crate::ui::icons::COPY, 11.0),
text(t!("Copy")).size(11),
]
.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::themed_warning(crate::ui::icons::TRASH, 11.0),
text(t!("Clear")).size(11),
]
.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, 8]);
let panel = container(column![header, log])
.width(Length::Fill)
.padding(Padding { top: 2.0, right: 8.0, bottom: 4.0, left: 8.0 });
let resize = iced::widget::mouse_area(
container(crate::ui::icons::themed_primary(
crate::ui::icons::RESIZE,
15.0,
))
.padding([0, 2]),
)
.on_press(Message::CommandHistoryResizeGrab)
.on_double_click(Message::CommandHistoryHeightReset)
.interaction(iced::mouse::Interaction::ResizingVertically);
let panel = stack![
panel,
container(resize)
.width(Length::Fill)
.height(Length::Fill)
.align_x(iced::alignment::Horizontal::Left)
.align_y(iced::alignment::Vertical::Top),
];
opaque(panel).into()
} else {
container(column![]).height(0).into()
};
// The transient recent lines and the full archive are two views of the
// same history. Showing both while the archive is open creates a fake
// second history region and visually disconnects the input row (#555).
let recent_history: Element<'a, Message> = if self.history_open || !has_recent_history {
container(column![]).height(0).into()
} else {
container(history_rows)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.base.color)),
..Default::default()
})
.width(Length::Fill)
.padding([2, 0])
.into()
};
let history_divider: Element<'a, Message> = if self.history_open {
rule::horizontal(1).into()
} else {
container(column![]).height(0).into()
};
let history_open = self.history_open;
container(
column![
autocomplete,
dropdown,
recent_history,
history_divider,
container(input_row)
.style(|theme: &Theme| {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(palette.background.weakest.color)),
..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)),
]
.width(Length::Fill),
)
.style(move |theme: &Theme| {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(palette.background.base.color)),
border: Border {
color: palette.background.neutral.color,
width: if history_open { 1.0 } else { 0.0 },
radius: 4.0.into(),
},
..Default::default()
}
})
.width(Length::Fill.max(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.
///
/// `aliases` is the `alias → command` table. Aliases themselves are dropped from
/// the results, so a terse `CC` is typeable and dispatches (via the alias table)
/// but never clutters the suggestions — only its target `COPYCLIP` is offered.
/// And when the whole input is an alias, its target command is forced to the top
/// so the highlighted suggestion matches what pressing Enter actually runs
/// (typing `AA` highlights `AREA`, `L` highlights `LINE`). (#288)
pub fn ranked_matches(
needle: &str,
dynamic: &[String],
aliases: &rustc_hash::FxHashMap<String, 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()))
// Hide aliases (keys of the table); their target command still shows.
.filter(|cmd| cmd.contains(&needle) && !aliases.contains_key(cmd))
.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))
});
// If the entire input is an alias, its target is what Enter runs, so make it
// the first (highlighted) suggestion — even when the target has no substring
// overlap with the alias (`AA` → `AREA`) and so wouldn't otherwise appear.
if let Some(target) = aliases.get(&needle) {
matches.retain(|m| m != target);
matches.insert(0, target.clone());
}
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: &Theme, status: button::Status) -> button::Style {
let palette = theme.palette();
let pair = if matches!(status, button::Status::Hovered | button::Status::Pressed) {
palette.background.weak
} else {
palette.background.weakest
};
button::Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
}
}
fn history_color(theme: &Theme, kind: &EntryKind) -> Color {
let palette = theme.palette();
match kind {
EntryKind::Command => palette.background.base.text,
EntryKind::Output => palette.background.base.text.scale_alpha(0.72),
EntryKind::Error => palette.danger.base.color,
EntryKind::Info => accessible_accent_threshold(
palette.primary.base.color,
palette.background.base.color,
palette.background.base.text,
4.5,
),
}
}
fn history_highlight_format(
kind: &EntryKind,
theme: &Theme,
) -> iced::advanced::text::highlighter::Format<iced::Font> {
iced::advanced::text::highlighter::Format {
color: Some(history_color(theme, kind)),
font: match kind {
EntryKind::Error => Some(iced::Font::DEFAULT.weight(iced::font::Weight::Bold)),
_ => None,
},
}
}
#[cfg(test)]
mod tests {
use super::{
format_error, history_highlight_format, mcp_status, ranked_matches, CommandLine, EntryKind,
ERROR_PREFIX, INFO_PREFIX,
};
use crate::t;
use rustc_hash::FxHashMap;
fn aliases(pairs: &[(&str, &str)]) -> FxHashMap<String, String> {
pairs
.iter()
.map(|(a, c)| (a.to_string(), c.to_string()))
.collect()
}
#[test]
fn a_warning_is_shown_without_counting_as_a_failed_command() {
let mut line = CommandLine::new();
let revision = line.error_revision;
line.push_warning("GPU unavailable");
let last = line.history.last().expect("warning must be appended");
assert_eq!(last.kind, super::EntryKind::Error);
assert!(last.text.ends_with("GPU unavailable"), "{}", last.text);
assert_eq!(line.error_revision, revision);
assert!(line.last_error.is_none());
}
#[test]
fn mcp_status_distinguishes_off_ready_and_busy() {
assert_eq!(mcp_status(false, false).0, "MCP control is off");
assert_eq!(mcp_status(true, false).0, "MCP control is ready");
assert_eq!(mcp_status(true, true).0, "MCP is handling a request");
}
#[test]
fn dynamic_plugin_commands_surface_in_autocomplete() {
let none = FxHashMap::default();
// 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_", &[], &none).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, &none);
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", &[], &FxHashMap::default());
assert!(m.iter().any(|c| c == "LINE"), "got {m:?}");
}
#[test]
fn aliases_are_hidden_but_their_command_survives() {
// The alias `L` is dropped from suggestions while `LINE` (its target)
// still appears. (#288)
let a = aliases(&[("L", "LINE")]);
let m = ranked_matches("L", &[], &a);
assert!(
!m.iter().any(|c| c == "L"),
"alias L should be hidden: {m:?}"
);
assert!(m.iter().any(|c| c == "LINE"), "LINE should remain: {m:?}");
}
#[test]
fn exact_alias_forces_its_target_to_the_top() {
// Typing a full alias highlights the command Enter will run, even when
// the target shares no substring with the alias (`AA` → `AREA`). (#288)
let a = aliases(&[("AA", "AREA"), ("L", "LINE")]);
let m = ranked_matches("AA", &[], &a);
assert_eq!(m.first().map(String::as_str), Some("AREA"), "got {m:?}");
let m = ranked_matches("L", &[], &a);
assert_eq!(m.first().map(String::as_str), Some("LINE"), "got {m:?}");
}
#[test]
fn history_markers_and_copy_text_are_clean() {
let mut line = CommandLine::new();
line.clear_history();
let command = "LINE";
let info = "Object selected.";
let error = "Unable to save: file is in use.";
let output = "12.5, 7.0\nraw output";
line.push_command(command);
line.push_info(info);
line.push_error(error);
line.push_output(output);
let expected = vec![
format!("❯ {} {command}", t!("Command:")),
format!("{INFO_PREFIX}{info}"),
format!("{ERROR_PREFIX}{}: {error}", t!("Invalid").to_uppercase()),
output.to_string(),
];
let actual = line
.history
.iter()
.map(|entry| entry.text.as_str())
.collect::<Vec<_>>();
assert_eq!(
actual,
expected.iter().map(String::as_str).collect::<Vec<_>>()
);
assert_eq!(
line.history
.iter()
.map(|entry| entry.kind.clone())
.collect::<Vec<_>>(),
vec![
EntryKind::Command,
EntryKind::Info,
EntryKind::Error,
EntryKind::Output,
]
);
let plain = line.history_plain_text();
assert!(!plain.contains('*'));
assert_eq!(plain, expected.join("\n"));
assert_eq!(line.history[3].text.as_bytes(), output.as_bytes());
}
#[test]
fn error_history_uses_bold_highlight_format() {
let theme = iced::Theme::Light;
let bold = iced::Font::DEFAULT.weight(iced::font::Weight::Bold);
assert_eq!(
history_highlight_format(&EntryKind::Error, &theme).font,
Some(bold)
);
for kind in [EntryKind::Command, EntryKind::Info, EntryKind::Output] {
assert_eq!(history_highlight_format(&kind, &theme).font, None);
}
}
#[test]
fn issue_498_repeated_save_error_is_not_duplicated() {
let mut line = CommandLine::new();
let initial_len = line.history.len();
let message = t!("Unable to save: file is in use.");
let expected_error = format_error(message.as_ref());
line.push_error_once(message.as_ref());
line.push_error_once(message.as_ref());
assert_eq!(line.history.len(), initial_len + 1);
assert_eq!(
line.history.last().map(|entry| entry.text.as_str()),
Some(expected_error.as_str())
);
assert!(!line.history_plain_text().contains('*'));
}
#[test]
fn command_recall_walks_both_directions_and_restores_the_draft() {
let mut line = CommandLine::new();
for command in ["LINE", "LINE", "CIRCLE"] {
line.record_recent(command);
}
line.input = "PARTIAL".to_string();
line.history_prev();
assert_eq!(line.input, "CIRCLE");
line.history_prev();
assert_eq!(line.input, "LINE");
line.history_prev();
assert_eq!(line.input, "LINE");
line.history_next();
assert_eq!(line.input, "CIRCLE");
line.history_next();
assert_eq!(line.input, "PARTIAL");
assert_eq!(
line.cmd_recall,
vec!["LINE".to_string(), "CIRCLE".to_string()]
);
}
#[test]
fn recalled_command_can_be_edited_before_submit() {
let mut line = CommandLine::new();
line.record_recent("MOVE");
line.history_prev();
line.input.push_str(" 0,0 10,0");
let submitted = line.submit().expect("edited command");
assert_eq!(submitted, "MOVE 0,0 10,0");
line.record_recent(&submitted);
assert_eq!(
line.cmd_recall.last().map(String::as_str),
Some("MOVE 0,0 10,0")
);
}
#[test]
fn commandline_fade_defaults_to_3000ms() {
let line = CommandLine::new();
assert_eq!(line.commandline_fade_ms(), 3000);
}
#[test]
fn commandline_fade_zero_hides_unpinned_but_keeps_pinned() {
let mut line = CommandLine::new();
line.push_info("transient");
assert!(line.has_visible_history());
line.set_commandline_fade_ms(0);
// Non-pinned entries are skipped entirely at 0.
assert!(!line.has_visible_history());
assert_eq!(line.visible_history_count(), 0);
// Pinned step prompt still shows at 0.
line.set_step_prompt(Some("Specify point:".to_string()));
assert!(line.has_visible_history());
}
#[test]
fn info_entries_carry_prefix_marker() {
let mut line = CommandLine::new();
line.push_info("Object selected.");
assert_eq!(
line.history.last().map(|entry| entry.text.as_str()),
Some("ⓘ Object selected.")
);
assert_eq!(
line.history.last().map(|entry| &entry.kind),
Some(&EntryKind::Info)
);
}
#[test]
fn set_step_prompt_reuses_push_info_entry() {
let mut line = CommandLine::new();
let initial_len = line.history.len();
line.push_info("Specify first point:");
assert_eq!(line.history.len(), initial_len + 1);
line.set_step_prompt(Some("Specify first point:".to_string()));
assert_eq!(
line.history.len(),
initial_len + 1,
"prompt must reuse push_info entry without duplicating"
);
let last = line.history.last().unwrap();
assert!(last.pinned);
assert_eq!(last.text, "ⓘ Specify first point:");
}
}