forked from 1b-agent/opencadstudio-upstream
1313 lines
51 KiB
Rust
1313 lines
51 KiB
Rust
//! 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:");
|
||
}
|
||
}
|