//! OpenCADStudio-style command line — bottom panel with input and history use iced::time::Instant; use crate::app::Message; use iced::widget::{button, column, container, opaque, row, scrollable, text, text_input}; use iced::{Background, Border, Color, Element, Length, Theme}; pub const CMD_INPUT_ID: &str = "cmd_input"; /// How long a history entry stays visible on the overlay before fading /// out. Picking the full archive happens through the dropdown button. const HISTORY_VISIBLE_SECS: f32 = 3.0; /// How many autocomplete matches the suggestion popup shows at once. const AUTOCOMPLETE_LIMIT: usize = 8; fn cmd_input_id() -> iced::widget::Id { iced::widget::Id::new(CMD_INPUT_ID) } const MAX_HISTORY: usize = 64; #[derive(Clone, Default)] pub struct CommandLine { pub input: String, pub history: Vec, /// Commands the user has typed (for ↑/↓ recall). pub cmd_recall: 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, /// Index of the currently-highlighted autocomplete suggestion, or /// `None` when the user hasn't yet started navigating with the /// arrow keys. Reset on every keystroke. pub autocomplete_cursor: Option, /// 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, } #[derive(Clone, Debug)] pub struct HistoryEntry { pub kind: EntryKind, pub text: String, /// When this entry was pushed. Used by the overlay to fade entries /// out after `HISTORY_VISIBLE_SECS`. The dropdown popup ignores it /// and always shows the whole list. pub created_at: Instant, /// The active command step's prompt is pinned so it does not fade /// while the user is still working on that step. When the step /// completes the pin is cleared and the normal cooldown resumes. pub pinned: bool, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum EntryKind { Command, Output, Error, Info, } impl CommandLine { pub fn new() -> Self { let mut cl = Self::default(); cl.push_info("Open CAD Studio ready."); cl.push_info("Type a command or use the ribbon. Open OBJ: INSERT tab."); cl } pub fn submit(&mut self) -> Option { let raw = self.input.trim().to_string(); if raw.is_empty() { return None; } // Uppercase only the command verb (the first token); keep arguments // verbatim so case-sensitive values survive — file paths on // case-sensitive filesystems, identifiers, plugin command arguments. // Dispatch matches verbs in uppercase and each sub-command handler // re-uppercases its own keywords, so only free-form args are affected. let cmd = match raw.split_once(char::is_whitespace) { Some((verb, rest)) => format!("{} {}", verb.to_uppercase(), rest), None => raw.to_uppercase(), }; // Record in recall list (avoid duplicates at the top). if self.cmd_recall.last().map(|s| s.as_str()) != Some(raw.as_str()) { self.cmd_recall.push(raw); if self.cmd_recall.len() > 50 { self.cmd_recall.remove(0); } } self.recall_cursor = None; self.recall_draft.clear(); self.push_command(&self.input.clone()); self.input.clear(); Some(cmd) } /// Navigate to the previous command in recall history (↑). pub fn history_prev(&mut self) { if self.cmd_recall.is_empty() { return; } let cursor = match self.recall_cursor { None => { self.recall_draft = self.input.clone(); self.cmd_recall.len() - 1 } Some(c) if c > 0 => c - 1, Some(c) => c, }; self.recall_cursor = Some(cursor); self.input = self.cmd_recall[cursor].clone(); } /// Navigate to the next command in recall history (↓). pub fn history_next(&mut self) { match self.recall_cursor { None => {} Some(c) if c + 1 < self.cmd_recall.len() => { let next = c + 1; self.recall_cursor = Some(next); self.input = self.cmd_recall[next].clone(); } Some(_) => { self.recall_cursor = None; self.input = self.recall_draft.clone(); } } } pub fn push_command(&mut self, cmd: &str) { self.push(EntryKind::Command, format!("Command: {cmd}")); } pub fn push_output(&mut self, msg: &str) { self.push(EntryKind::Output, msg.to_string()); } pub fn push_error(&mut self, msg: &str) { self.push(EntryKind::Error, format!("*Invalid* {msg}")); } pub fn push_info(&mut self, msg: &str) { self.push(EntryKind::Info, msg.to_string()); } fn push(&mut self, kind: EntryKind, text: String) { self.history.push(HistoryEntry { kind, text, created_at: Instant::now(), pinned: false, }); if self.history.len() > MAX_HISTORY { self.history.remove(0); } } /// Mirror the active command step's prompt. While the step is current /// its history line is pinned so it does not fade; when the step /// changes (or the command ends, `prompt == None`) the previous line's /// cooldown restarts so it fades normally from now. The dispatch / /// step-transition code already pushes the prompt as an `Info` line, so /// the matching tail entry is pinned in place rather than duplicated. pub fn set_step_prompt(&mut self, prompt: Option) { if prompt == self.step_prompt { return; } // Release the previous pin and let its cooldown start now. for e in self.history.iter_mut().filter(|e| e.pinned) { e.pinned = false; e.created_at = Instant::now(); } if let Some(p) = &prompt { // Reuse the prompt line dispatch/step-transition just pushed; // otherwise add it. if self.history.last().map(|e| &e.text) != Some(p) { self.push(EntryKind::Info, p.clone()); } if let Some(last) = self.history.last_mut() { last.pinned = true; } } self.step_prompt = prompt; } /// `true` while at least one history entry is still within the /// visible window — the host app uses this to drive a low-frequency /// tick subscription so the overlay re-renders and fades the entry /// once it expires. pub fn has_visible_history(&self) -> bool { self.history .iter() .any(|e| e.pinned || e.created_at.elapsed().as_secs_f32() < HISTORY_VISIBLE_SECS) } pub fn toggle_history(&mut self) { self.history_open = !self.history_open; } pub fn close_history(&mut self) { self.history_open = false; } /// Move the autocomplete highlight up one entry. Wraps to the last /// match. Returns `true` when there was a list to navigate. pub fn autocomplete_prev(&mut self) -> bool { let len = self.autocomplete_matches().len(); if len == 0 { return false; } let next = match self.autocomplete_cursor { None => len - 1, Some(0) => len - 1, Some(i) => i - 1, }; self.autocomplete_cursor = Some(next); true } /// Move the autocomplete highlight down one entry. Wraps to the /// first match. Returns `true` when there was a list to navigate. pub fn autocomplete_next(&mut self) -> bool { let len = self.autocomplete_matches().len(); if len == 0 { return false; } let next = match self.autocomplete_cursor { None => 0, Some(i) if i + 1 < len => i + 1, Some(_) => 0, }; self.autocomplete_cursor = Some(next); true } /// The command name the user has currently highlighted in the /// autocomplete popup, if any. pub fn selected_suggestion(&self) -> Option<&'static str> { let matches = self.autocomplete_matches(); self.autocomplete_cursor .and_then(|i| matches.get(i).copied()) } /// Autocomplete suggestions for the current input, capped at /// [`AUTOCOMPLETE_LIMIT`]. Match is substring-anywhere (typing /// `LEADER` surfaces `LEADER` and `MLEADER` and `QLEADER`); the /// list is sorted so prefix matches come first. /// /// Names come from `crate::command::all_registered_command_names()` /// which collects every `inventory::submit!` block placed next to a /// `CadCommand` impl — no central list to maintain. pub fn autocomplete_matches(&self) -> Vec<&'static str> { let typed = self.input.trim(); if typed.is_empty() { return Vec::new(); } let needle = typed.to_uppercase(); let mut matches: Vec<&'static str> = crate::command::all_registered_command_names() .into_iter() .filter(|cmd| cmd.contains(&needle)) .collect(); matches.sort(); matches.dedup(); // Prefix matches rank above mid-string ones, then alphabetical // so the order is stable as the user keeps typing. matches.sort_by_key(|cmd| (!cmd.starts_with(&needle), *cmd)); matches.truncate(AUTOCOMPLETE_LIMIT); matches } pub fn view(&self, show_autocomplete: bool, dyn_capturing: bool) -> Element<'_, Message> { // Only the most recent entries pushed within the last few // seconds show on the overlay. The dropdown button keeps the // full backlog reachable when the user actually wants it. let visible: Vec<&HistoryEntry> = self .history .iter() .filter(|e| e.pinned || e.created_at.elapsed().as_secs_f32() < HISTORY_VISIBLE_SECS) .collect(); let start = visible.len().saturating_sub(4); let history_rows = visible[start..] .iter() .fold(column![].spacing(0), |col, entry| { let color = match entry.kind { EntryKind::Command => CMD_COLOR, EntryKind::Output => OUT_COLOR, EntryKind::Error => ERR_COLOR, EntryKind::Info => INFO_COLOR, }; col.push(container(text(&entry.text).size(11).color(color)).padding([1, 8])) }); let prompt = container(text("Command:").size(11).color(PROMPT_COLOR)).padding([5, 8]); // While dynamic input is capturing keystrokes, the command-line // text field is left without an `on_input` handler so it can't // grab focus or swallow numeric keys — those flow through the // global key subscription into the dynamic-input fields instead. let mut input = text_input("", &self.input).id(cmd_input_id()); if !dyn_capturing { input = input .on_input(Message::CommandInput) .on_submit(Message::CommandSubmit); } let input = input .style(|_: &Theme, _| text_input::Style { background: Background::Color(INPUT_BG), border: Border { color: Color { r: 0.40, g: 0.60, b: 0.90, a: 1.0, }, width: 1.0, radius: 2.0.into(), }, icon: Color::WHITE, placeholder: Color { r: 0.4, g: 0.4, b: 0.4, a: 1.0, }, value: Color::WHITE, selection: Color { r: 0.20, g: 0.44, b: 0.72, a: 0.5, }, }) .size(11) .padding([4, 6]); // Autocomplete suggestions panel, shown above the input row // when the user has typed a prefix that matches at least one // command. Each row is a button — clicking it dispatches the // command directly. let autocomplete: Element<'_, Message> = if show_autocomplete { let matches = self.autocomplete_matches(); if matches.is_empty() { container(column![]).height(0).into() } else { let cursor = self.autocomplete_cursor; let mut col = column![].spacing(0).width(Length::Fill); for (idx, cmd) in matches.iter().enumerate() { let is_selected = cursor == Some(idx); let row = button(text(*cmd).size(11).color(CMD_COLOR)) .on_press(Message::CommandSuggestionPick(cmd.to_string())) .width(Length::Fill) .padding([2, 8]) .style(move |_: &Theme, status| { let bg = if is_selected { Color { r: 0.28, g: 0.40, b: 0.56, a: 1.0, } } else if matches!(status, button::Status::Hovered) { Color { r: 0.22, g: 0.30, b: 0.42, a: 1.0, } } else { PANEL_BG }; button::Style { background: Some(Background::Color(bg)), text_color: Color::WHITE, border: Border::default(), ..Default::default() } }); col = col.push(row); } container(col) .style(|_: &Theme| container::Style { background: Some(Background::Color(PANEL_BG)), border: Border { color: BORDER_COLOR, width: 1.0, radius: 4.0.into(), }, ..Default::default() }) .width(Length::Fill) .into() } } else { container(column![]).height(0).into() }; // Dropdown trigger next to the input. Clicking it pops up the // full backlog (everything pushed since the app started) so the // user can recover anything that has already faded off the // overlay. let dropdown_icon = if self.history_open { crate::ui::icons::arrow_down(11.0, PROMPT_COLOR) } else { crate::ui::icons::arrow_right(11.0, PROMPT_COLOR) }; let dropdown_btn = button(dropdown_icon) .on_press(Message::CommandHistoryToggle) .style(|_: &Theme, _status| button::Style { background: Some(Background::Color(INPUT_ROW_BG)), text_color: Color::WHITE, border: Border { color: BORDER_COLOR, width: 1.0, radius: 3.0.into(), }, ..Default::default() }) .padding([2, 6]); let input_row = row![prompt, input, dropdown_btn] .spacing(4) .align_y(iced::Center); // Full backlog dropdown — appears ABOVE the input pill when // open. The history `Vec` already contains every line pushed // since startup; render them all (newest at the bottom) in a // scrollable. `opaque` wraps the panel so mouse-wheel events // inside the dropdown don't bubble through to the viewport // shader behind it (otherwise scrolling the history zoomed // the drawing). `anchor_bottom` keeps the newest line in view // when the dropdown first opens. let dropdown: Element<'_, Message> = if self.history_open { let mut col = column![].spacing(0).width(Length::Fill); for entry in &self.history { let color = match entry.kind { EntryKind::Command => CMD_COLOR, EntryKind::Output => OUT_COLOR, EntryKind::Error => ERR_COLOR, EntryKind::Info => INFO_COLOR, }; col = col.push( container(text(&entry.text).size(11).color(color)).padding([1, 8]), ); } let panel = container( scrollable(col).anchor_bottom().width(Length::Fill), ) .style(|_: &Theme| container::Style { background: Some(Background::Color(PANEL_BG)), border: Border { color: BORDER_COLOR, width: 1.0, radius: 4.0.into(), }, ..Default::default() }) .width(Length::Fill) .max_height(200.0) .padding([4, 0]); opaque(panel).into() } else { container(column![]).height(0).into() }; container(column![ autocomplete, dropdown, container(history_rows) .style(|_: &Theme| container::Style { background: Some(Background::Color(HISTORY_BG)), ..Default::default() }) .width(Length::Fill) .padding([2, 0]), container(input_row) .style(|_: &Theme| container::Style { background: Some(Background::Color(INPUT_ROW_BG)), border: Border { color: BORDER_COLOR, width: 1.0, radius: 3.0.into() }, ..Default::default() }) .width(Length::Fill), ]) .style(|_: &Theme| container::Style { background: Some(Background::Color(PANEL_BG)), border: Border { color: BORDER_COLOR, width: 1.0, radius: 4.0.into(), }, ..Default::default() }) .width(Length::Fixed(720.0)) .into() } } const PANEL_BG: Color = Color { r: 0.15, g: 0.15, b: 0.15, a: 1.0, }; const HISTORY_BG: Color = Color { r: 0.15, g: 0.15, b: 0.15, a: 1.0, }; const INPUT_ROW_BG: Color = Color { r: 0.18, g: 0.18, b: 0.18, a: 1.0, }; const INPUT_BG: Color = Color { r: 0.12, g: 0.12, b: 0.12, a: 1.0, }; const BORDER_COLOR: Color = Color { r: 0.30, g: 0.30, b: 0.30, a: 1.0, }; const PROMPT_COLOR: Color = Color { r: 0.55, g: 0.78, b: 0.55, a: 1.0, }; const CMD_COLOR: Color = Color { r: 0.80, g: 0.80, b: 0.80, a: 1.0, }; const OUT_COLOR: Color = Color { r: 0.65, g: 0.65, b: 0.65, a: 1.0, }; const ERR_COLOR: Color = Color { r: 0.90, g: 0.35, b: 0.35, a: 1.0, }; const INFO_COLOR: Color = Color { r: 0.50, g: 0.70, b: 0.90, a: 1.0, };