//! 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; /// Maximum entries kept for the context menu's "Recent Input" list. pub const RECENT_INPUT_CAP: usize = 10; 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) } /// Footer pill state for the automation channel. `waiting` — a client /// `user_select` / `getpoint` is parked and the person at the screen must /// answer — outranks `busy`: the screen has to say "act now", not just /// "something is running". fn mcp_status(enabled: bool, busy: bool, waiting: bool) -> (&'static str, Color) { if !enabled { ( "MCP control is off", Color::from_rgb(0.90, 0.35, 0.35), ) } else if waiting { ( "MCP is waiting for you to pick — Enter confirms, Esc cancels", Color::from_rgb(0.30, 0.55, 0.98), ) } 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. Only a /// bracket group that reads as a listing goes (several choices, or a single /// choice that is one of `options`); an informational group such as /// "[3 objects]" or "[base 1.0,2.0]" stays. Collapses the leftover double /// spaces and the orphaned " :" the removal leaves behind. fn strip_option_listing(s: &str, options: &[CmdOption]) -> String { let Some(a) = s.find('[') else { return s.to_string(); }; let Some(off) = s[a..].find(']') else { return s.to_string(); }; let inner = s[a + 1..a + off].trim(); let is_listing = inner.contains('/') || inner.contains('|') || inner.contains('=') || options.iter().any(|o| { o.label.eq_ignore_ascii_case(inner) || o.keyword.eq_ignore_ascii_case(inner) }); if !is_listing { 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, pub error_revision: u64, pub last_error: Option, /// Tokens from the last multi-token command line that no prompt ever /// consumed — e.g. a trailing word after an in-place text step, or a typo'd /// extra argument (`CIRCLE 0,0 5 9`). The headless automation feeder reports /// them so a caller can tell "part of my line was silently dropped" from /// "the command ran exactly as typed". Cleared at the start of every /// `run_command_line`, not persisted. pub unconsumed: Vec, /// Successfully dispatched commands used for ↑/↓ recall, newest last. /// Stored separately because recall also maintains its own cursor and draft. pub cmd_recall: Vec, /// 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, /// Tokens typed into a running command (points, distances, keywords), /// newest first, de-duplicated. Drives the context menu's "Recent Input" /// submenu so a value can be re-fed with one click. Not persisted. pub recent_inputs: Vec, /// Current position in `cmd_recall` while navigating (None = not navigating). recall_cursor: Option, /// 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. `None` /// means the first match is pre-selected (highlighted) before keyboard /// navigation begins. Reset when input changes. pub autocomplete_cursor: Option, /// 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, /// 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, /// 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, /// 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, /// 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, unconsumed: Vec::new(), cmd_recall: Vec::new(), recent_commands: Vec::new(), recent_inputs: 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, } #[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, 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 { 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(); } /// Remember a token fed to a running command for the "Recent Input" /// submenu: newest first, case-insensitive move-to-front de-duplication, /// capped at `RECENT_INPUT_CAP`. Empty, overly long and entity-handle /// tokens are skipped (a handle is meaningless outside the pick it /// answered). pub fn record_recent_input(&mut self, token: &str) { let token = token.trim(); if token.is_empty() || token.chars().count() > 40 { return; } let upper = token.to_uppercase(); self.recent_inputs.retain(|t| t.to_uppercase() != upper); self.recent_inputs.insert(0, token.to_string()); self.recent_inputs.truncate(RECENT_INPUT_CAP); } 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) { 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) { 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()) } /// Height of the overlaid prompt lines currently shown above the input /// row (CLIPROMPTLINES, minus lines that have faded out), in pixels. Lets /// cursor-anchored panels stay clear of the whole command area, not just /// the input row. pub fn overlay_lines_height(&self) -> f32 { if self.cliprompt_lines == 0 || self.history_open { return 0.0; } let visible = self .history .iter() .filter(|e| self.entry_visible(e)) .count() .min(self.cliprompt_lines as usize); // Text size 12 (line height ≈ 15.6) plus the 1 px padding each side. visible as f32 * 18.0 } /// 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::>() .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 highlighted in the autocomplete popup. Before any /// arrow-key navigation the first match is pre-selected (the popup renders /// `autocomplete_cursor.unwrap_or(0)` as highlighted), so Enter runs that /// entry — never a different command resolved through another path. pub fn selected_suggestion(&self) -> Option { let matches = self.autocomplete_matches(); if matches.is_empty() { return None; } let index = self .autocomplete_cursor .unwrap_or(0) .min(matches.len() - 1); matches.get(index).cloned() } /// Autocomplete suggestions for the current input — see /// [`ranked_matches`]. Includes loaded plugins' commands (#272). pub fn autocomplete_matches(&self) -> Vec { 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, pick_pending: bool, graph_open: 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, &self.step_options); 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; // Every row keeps the icon's width so names line up. 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(), }; let label = row![icon, text(cmd.clone()).size(11)] .spacing(6) .align_y(iced::Center); let row = button(label) .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, pick_pending); 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 graph_btn = button(crate::ui::icons::themed(crate::ui::icons::NODE_GRAPH, 13.0)) .on_press(Message::Graph(crate::ui::node_graph::GraphMsg::Toggle)) .style(move |theme: &Theme, status| { if graph_open { button::primary(theme, status) } else { button::subtle(theme, status) } }) .padding([2, 6]); let graph_tip = container(text(t!("Node graph")).size(11)) .padding([3, 6]) .style(container::bordered_box); let graph_btn = tooltip(graph_btn, graph_tip, tooltip::Position::Top).gap(4); let input_row = row![prompt, literal_btn, input_with_history, graph_btn, 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::( 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, ) -> Vec { let needle = needle.trim().to_uppercase(); if needle.is_empty() { return Vec::new(); } let mut matches: Vec = 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::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 { 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, false).0, "MCP control is off"); assert_eq!(mcp_status(true, false, false).0, "MCP control is ready"); assert_eq!(mcp_status(true, true, false).0, "MCP is handling a request"); // A parked pick outranks the plain busy state: the person must act. assert_eq!( mcp_status(true, true, true).0, "MCP is waiting for you to pick — Enter confirms, Esc cancels" ); } #[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 preselected_top_suggestion_is_returned_without_navigation() { // The popup highlights the first match before any arrow-key navigation // (`unwrap_or(0)` in `view`); Enter must run that same entry. Typing // `LT` with no alias table highlights `LTSCALE`, so the pre-selection // must be `LTSCALE` — not `None` (which would fall through to alias / // closest-match resolution and could run a different command). let mut line = CommandLine::new(); line.clear_history(); line.command_aliases = FxHashMap::default(); line.input = "LT".to_string(); line.autocomplete_cursor = None; let matches = line.autocomplete_matches(); assert_eq!( matches.first().map(String::as_str), Some("LTSCALE"), "got {matches:?}" ); assert_eq!( line.selected_suggestion().as_deref(), Some("LTSCALE"), "Enter must run the highlighted pre-selection" ); } #[test] fn preselected_alias_target_is_returned_without_navigation() { // With the `LT` → `LINETYPE` alias, the forced top entry is `LINETYPE` // and Enter must run it without requiring arrow-key navigation. let mut line = CommandLine::new(); line.clear_history(); line.command_aliases = aliases(&[("LT", "LINETYPE"), ("LTS", "LTSCALE")]); line.input = "LT".to_string(); line.autocomplete_cursor = None; assert_eq!( line.selected_suggestion().as_deref(), Some("LINETYPE"), "got {:?}", line.autocomplete_matches() ); } #[test] fn no_preselection_without_matches_or_input() { let mut line = CommandLine::new(); line.clear_history(); line.input = String::new(); line.autocomplete_cursor = None; assert_eq!(line.selected_suggestion(), None); line.input = "ZZZ_NO_SUCH_COMMAND".to_string(); assert_eq!(line.selected_suggestion(), None); } #[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::>(); assert_eq!( actual, expected.iter().map(String::as_str).collect::>() ); assert_eq!( line.history .iter() .map(|entry| entry.kind.clone()) .collect::>(), 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:"); } }