2026-05-23 17:16:15 +03:00
|
|
|
//! OpenCADStudio-style command line — bottom panel with input and history
|
2026-03-22 14:56:27 +03:00
|
|
|
|
2026-06-16 17:53:56 +03:00
|
|
|
use iced::time::Instant;
|
2026-05-26 19:28:09 +03:00
|
|
|
|
2026-03-22 22:44:32 +03:00
|
|
|
use crate::app::Message;
|
feat(command): clickable option buttons in the command line (#304)
Commands now expose their step options as clickable buttons rendered
inline beside the command-line prompt, so options need not be typed.
Adds a CmdOption/options() API to CadCommand: each command returns the
options for its current step; the command line renders them as
uppercase buttons and a click feeds the keyword to the command exactly
as if typed (an empty keyword submits like Enter).
Construction commands become "central" — CIRCLE offers [3P/2P/TTR/TTT/
DIAMETER], ELLIPSE [ARC/AXIS], ARC [SCE/SCA/SEA/SER/CSA/3P], RECTANGLE
[ROTATION/CENTER], POLYGON [CIRCUMSCRIBED/EDGE] — each keyword hands off
to the existing dedicated variant command via CmdResult::Dispatch,
instead of the variants only being reachable as isolated commands.
Migrates the existing bracketed-option prompts (PLINE, PEDIT, SPLINE,
SCALE, FILLET, CHAMFER, HATCH, GRADIENT, SOLID, POINT, RAY, XLINE, TRIM,
EXTEND) to the same button API and strips the option text from the
prompt strings, keeping value/coordinate hints. PEDIT's Width option
now enters a value sub-step so the button acts on click.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:16:59 +03:00
|
|
|
use crate::command::CmdOption;
|
2026-07-01 18:17:46 +03:00
|
|
|
use iced::widget::{
|
|
|
|
|
button, column, container, opaque, row, text, text_editor, text_input, Space,
|
|
|
|
|
};
|
2026-03-22 14:56:27 +03:00
|
|
|
use iced::{Background, Border, Color, Element, Length, Theme};
|
|
|
|
|
|
|
|
|
|
pub const CMD_INPUT_ID: &str = "cmd_input";
|
|
|
|
|
|
2026-05-26 19:28:09 +03:00
|
|
|
/// 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;
|
|
|
|
|
|
2026-05-26 22:45:54 +03:00
|
|
|
/// How many autocomplete matches the suggestion popup shows at once.
|
|
|
|
|
const AUTOCOMPLETE_LIMIT: usize = 8;
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
fn cmd_input_id() -> iced::widget::Id {
|
|
|
|
|
iced::widget::Id::new(CMD_INPUT_ID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const MAX_HISTORY: usize = 64;
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Default)]
|
|
|
|
|
pub struct CommandLine {
|
|
|
|
|
pub input: String,
|
|
|
|
|
pub history: Vec<HistoryEntry>,
|
2026-06-27 16:16:08 +03:00
|
|
|
/// Commands the user has typed (for ↑/↓ recall). Holds the raw typed
|
|
|
|
|
/// strings so the line can be re-edited; distinct from `recent_commands`.
|
2026-04-08 00:41:01 +03:00
|
|
|
pub cmd_recall: Vec<String>,
|
2026-06-27 16:16:08 +03:00
|
|
|
/// 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>,
|
2026-04-07 00:04:12 +03:00
|
|
|
/// 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,
|
2026-05-26 22:13:15 +03:00
|
|
|
/// When `true`, the dropdown showing the full backlog is open.
|
|
|
|
|
pub history_open: bool,
|
2026-05-26 22:45:54 +03:00
|
|
|
/// Index of the currently-highlighted autocomplete suggestion, or
|
|
|
|
|
/// `None` when the user hasn't yet started navigating with the
|
|
|
|
|
/// arrow keys. Reset on every keystroke.
|
|
|
|
|
pub autocomplete_cursor: Option<usize>,
|
2026-07-04 23:01:53 +03:00
|
|
|
/// 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>,
|
2026-06-09 18:46:45 +03:00
|
|
|
/// 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>,
|
feat(command): clickable option buttons in the command line (#304)
Commands now expose their step options as clickable buttons rendered
inline beside the command-line prompt, so options need not be typed.
Adds a CmdOption/options() API to CadCommand: each command returns the
options for its current step; the command line renders them as
uppercase buttons and a click feeds the keyword to the command exactly
as if typed (an empty keyword submits like Enter).
Construction commands become "central" — CIRCLE offers [3P/2P/TTR/TTT/
DIAMETER], ELLIPSE [ARC/AXIS], ARC [SCE/SCA/SEA/SER/CSA/3P], RECTANGLE
[ROTATION/CENTER], POLYGON [CIRCUMSCRIBED/EDGE] — each keyword hands off
to the existing dedicated variant command via CmdResult::Dispatch,
instead of the variants only being reachable as isolated commands.
Migrates the existing bracketed-option prompts (PLINE, PEDIT, SPLINE,
SCALE, FILLET, CHAMFER, HATCH, GRADIENT, SOLID, POINT, RAY, XLINE, TRIM,
EXTEND) to the same button API and strips the option text from the
prompt strings, keeping value/coordinate hints. PEDIT's Width option
now enters a value sub-step so the button acts on click.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:16:59 +03:00
|
|
|
/// 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>,
|
2026-03-22 14:56:27 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
pub struct HistoryEntry {
|
|
|
|
|
pub kind: EntryKind,
|
|
|
|
|
pub text: String,
|
2026-05-26 19:28:09 +03:00
|
|
|
/// 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,
|
2026-06-09 18:46:45 +03:00
|
|
|
/// 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,
|
2026-03-22 14:56:27 +03:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 13:52:15 -04:00
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
2026-03-22 14:56:27 +03:00
|
|
|
pub enum EntryKind {
|
|
|
|
|
Command,
|
|
|
|
|
Output,
|
|
|
|
|
Error,
|
|
|
|
|
Info,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl CommandLine {
|
|
|
|
|
pub fn new() -> Self {
|
|
|
|
|
let mut cl = Self::default();
|
2026-05-23 17:16:15 +03:00
|
|
|
cl.push_info("Open CAD Studio ready.");
|
2026-03-22 14:56:27 +03:00
|
|
|
cl.push_info("Type a command or use the ribbon. Open OBJ: INSERT tab.");
|
|
|
|
|
cl
|
|
|
|
|
}
|
2026-04-07 00:04:12 +03:00
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
pub fn submit(&mut self) -> Option<String> {
|
2026-06-16 04:46:27 +03:00
|
|
|
let raw = self.input.trim().to_string();
|
|
|
|
|
if raw.is_empty() {
|
2026-03-22 14:56:27 +03:00
|
|
|
return None;
|
|
|
|
|
}
|
2026-06-16 04:46:27 +03:00
|
|
|
// 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(),
|
|
|
|
|
};
|
2026-04-07 00:04:12 +03:00
|
|
|
// Record in recall list (avoid duplicates at the top).
|
2026-06-16 04:46:27 +03:00
|
|
|
if self.cmd_recall.last().map(|s| s.as_str()) != Some(raw.as_str()) {
|
2026-04-07 00:04:12 +03:00
|
|
|
self.cmd_recall.push(raw);
|
|
|
|
|
if self.cmd_recall.len() > 50 {
|
|
|
|
|
self.cmd_recall.remove(0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
self.recall_cursor = None;
|
|
|
|
|
self.recall_draft.clear();
|
2026-03-22 14:56:27 +03:00
|
|
|
self.push_command(&self.input.clone());
|
|
|
|
|
self.input.clear();
|
|
|
|
|
Some(cmd)
|
|
|
|
|
}
|
2026-04-07 00:04:12 +03:00
|
|
|
|
2026-06-27 16:16:08 +03:00
|
|
|
/// Record a dispatched command for the right-click "Repeat" menu, skipping
|
|
|
|
|
/// a consecutive duplicate and capping the list. Called from the dispatch
|
|
|
|
|
/// choke point so commands from every source are captured.
|
|
|
|
|
pub fn record_recent(&mut self, cmd: &str) {
|
|
|
|
|
let cmd = cmd.trim();
|
|
|
|
|
if cmd.is_empty() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if self.recent_commands.last().map(|s| s.as_str()) != Some(cmd) {
|
|
|
|
|
self.recent_commands.push(cmd.to_string());
|
|
|
|
|
if self.recent_commands.len() > 50 {
|
|
|
|
|
self.recent_commands.remove(0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 00:04:12 +03:00
|
|
|
/// 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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
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) {
|
2026-05-26 19:28:09 +03:00
|
|
|
self.history.push(HistoryEntry {
|
|
|
|
|
kind,
|
|
|
|
|
text,
|
|
|
|
|
created_at: Instant::now(),
|
2026-06-09 18:46:45 +03:00
|
|
|
pinned: false,
|
2026-05-26 19:28:09 +03:00
|
|
|
});
|
2026-03-22 14:56:27 +03:00
|
|
|
if self.history.len() > MAX_HISTORY {
|
|
|
|
|
self.history.remove(0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 18:46:45 +03:00
|
|
|
/// Mirror the active command step's prompt. While the step is current
|
|
|
|
|
/// its history line is pinned so it does not fade; when the step
|
|
|
|
|
/// changes (or the command ends, `prompt == None`) the previous line's
|
|
|
|
|
/// cooldown restarts so it fades normally from now. The dispatch /
|
|
|
|
|
/// step-transition code already pushes the prompt as an `Info` line, so
|
|
|
|
|
/// the matching tail entry is pinned in place rather than duplicated.
|
|
|
|
|
pub fn set_step_prompt(&mut self, prompt: Option<String>) {
|
|
|
|
|
if prompt == self.step_prompt {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// Release the previous pin and let its cooldown start now.
|
|
|
|
|
for e in self.history.iter_mut().filter(|e| e.pinned) {
|
|
|
|
|
e.pinned = false;
|
|
|
|
|
e.created_at = Instant::now();
|
|
|
|
|
}
|
|
|
|
|
if let Some(p) = &prompt {
|
|
|
|
|
// Reuse the prompt line dispatch/step-transition just pushed;
|
|
|
|
|
// otherwise add it.
|
|
|
|
|
if self.history.last().map(|e| &e.text) != Some(p) {
|
|
|
|
|
self.push(EntryKind::Info, p.clone());
|
|
|
|
|
}
|
|
|
|
|
if let Some(last) = self.history.last_mut() {
|
|
|
|
|
last.pinned = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
self.step_prompt = prompt;
|
|
|
|
|
}
|
|
|
|
|
|
feat(command): clickable option buttons in the command line (#304)
Commands now expose their step options as clickable buttons rendered
inline beside the command-line prompt, so options need not be typed.
Adds a CmdOption/options() API to CadCommand: each command returns the
options for its current step; the command line renders them as
uppercase buttons and a click feeds the keyword to the command exactly
as if typed (an empty keyword submits like Enter).
Construction commands become "central" — CIRCLE offers [3P/2P/TTR/TTT/
DIAMETER], ELLIPSE [ARC/AXIS], ARC [SCE/SCA/SEA/SER/CSA/3P], RECTANGLE
[ROTATION/CENTER], POLYGON [CIRCUMSCRIBED/EDGE] — each keyword hands off
to the existing dedicated variant command via CmdResult::Dispatch,
instead of the variants only being reachable as isolated commands.
Migrates the existing bracketed-option prompts (PLINE, PEDIT, SPLINE,
SCALE, FILLET, CHAMFER, HATCH, GRADIENT, SOLID, POINT, RAY, XLINE, TRIM,
EXTEND) to the same button API and strips the option text from the
prompt strings, keeping value/coordinate hints. PEDIT's Width option
now enters a value sub-step so the button acts on click.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:16:59 +03:00
|
|
|
/// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 19:28:09 +03:00
|
|
|
/// `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()
|
2026-06-09 18:46:45 +03:00
|
|
|
.any(|e| e.pinned || e.created_at.elapsed().as_secs_f32() < HISTORY_VISIBLE_SECS)
|
2026-05-26 19:28:09 +03:00
|
|
|
}
|
|
|
|
|
|
2026-05-26 22:13:15 +03:00
|
|
|
pub fn toggle_history(&mut self) {
|
|
|
|
|
self.history_open = !self.history_open;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn close_history(&mut self) {
|
|
|
|
|
self.history_open = false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-01 18:17:46 +03:00
|
|
|
/// The whole history flattened to plain text, one entry per line, for the
|
|
|
|
|
/// clipboard-copy button (issue #232). Entries carry no per-line prefix
|
|
|
|
|
/// beyond what `push_*` already baked into `text` (e.g. "Command: "), so
|
|
|
|
|
/// the pasted block reads the same as the on-screen log.
|
|
|
|
|
pub fn history_plain_text(&self) -> String {
|
|
|
|
|
self.history
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|e| e.text.as_str())
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join("\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Drop every history line. The step-prompt mirror is reset too so a
|
|
|
|
|
/// later step still repins correctly.
|
|
|
|
|
pub fn clear_history(&mut self) {
|
|
|
|
|
self.history.clear();
|
|
|
|
|
self.step_prompt = None;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 22:45:54 +03:00
|
|
|
/// 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;
|
|
|
|
|
}
|
2026-06-27 14:45:51 +03:00
|
|
|
// No explicit cursor means the top match (index 0) is highlighted,
|
|
|
|
|
// so ↑ from there wraps to the last entry.
|
|
|
|
|
let cur = self.autocomplete_cursor.unwrap_or(0);
|
|
|
|
|
let next = if cur == 0 { len - 1 } else { cur - 1 };
|
2026-05-26 22:45:54 +03:00
|
|
|
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;
|
|
|
|
|
}
|
2026-06-27 14:45:51 +03:00
|
|
|
// No explicit cursor means the top match (index 0) is highlighted,
|
|
|
|
|
// so ↓ from there advances to the next entry (wrapping at the end).
|
|
|
|
|
let cur = self.autocomplete_cursor.unwrap_or(0);
|
|
|
|
|
let next = if cur + 1 < len { cur + 1 } else { 0 };
|
2026-05-26 22:45:54 +03:00
|
|
|
self.autocomplete_cursor = Some(next);
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The command name the user has currently highlighted in the
|
|
|
|
|
/// autocomplete popup, if any.
|
2026-07-04 23:01:53 +03:00
|
|
|
pub fn selected_suggestion(&self) -> Option<String> {
|
2026-05-26 22:45:54 +03:00
|
|
|
let matches = self.autocomplete_matches();
|
|
|
|
|
self.autocomplete_cursor
|
2026-07-04 23:01:53 +03:00
|
|
|
.and_then(|i| matches.get(i).cloned())
|
2026-05-26 22:45:54 +03:00
|
|
|
}
|
|
|
|
|
|
2026-06-27 14:45:51 +03:00
|
|
|
/// Autocomplete suggestions for the current input — see
|
2026-07-04 23:01:53 +03:00
|
|
|
/// [`ranked_matches`]. Includes loaded plugins' commands (#272).
|
|
|
|
|
pub fn autocomplete_matches(&self) -> Vec<String> {
|
|
|
|
|
ranked_matches(self.input.trim(), &self.dynamic_commands)
|
2026-05-26 22:45:54 +03:00
|
|
|
}
|
|
|
|
|
|
2026-07-01 18:17:46 +03:00
|
|
|
pub fn view<'a>(
|
|
|
|
|
&'a self,
|
|
|
|
|
show_autocomplete: bool,
|
|
|
|
|
dyn_capturing: bool,
|
|
|
|
|
history_content: &'a text_editor::Content,
|
|
|
|
|
) -> Element<'a, Message> {
|
2026-05-26 19:28:09 +03:00
|
|
|
// 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()
|
2026-06-09 18:46:45 +03:00
|
|
|
.filter(|e| e.pinned || e.created_at.elapsed().as_secs_f32() < HISTORY_VISIBLE_SECS)
|
2026-05-26 19:28:09 +03:00
|
|
|
.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,
|
|
|
|
|
};
|
feat(command): clickable option buttons in the command line (#304)
Commands now expose their step options as clickable buttons rendered
inline beside the command-line prompt, so options need not be typed.
Adds a CmdOption/options() API to CadCommand: each command returns the
options for its current step; the command line renders them as
uppercase buttons and a click feeds the keyword to the command exactly
as if typed (an empty keyword submits like Enter).
Construction commands become "central" — CIRCLE offers [3P/2P/TTR/TTT/
DIAMETER], ELLIPSE [ARC/AXIS], ARC [SCE/SCA/SEA/SER/CSA/3P], RECTANGLE
[ROTATION/CENTER], POLYGON [CIRCUMSCRIBED/EDGE] — each keyword hands off
to the existing dedicated variant command via CmdResult::Dispatch,
instead of the variants only being reachable as isolated commands.
Migrates the existing bracketed-option prompts (PLINE, PEDIT, SPLINE,
SCALE, FILLET, CHAMFER, HATCH, GRADIENT, SOLID, POINT, RAY, XLINE, TRIM,
EXTEND) to the same button API and strips the option text from the
prompt strings, keeping value/coordinate hints. PEDIT's Width option
now enters a value sub-step so the button acts on click.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:16:59 +03:00
|
|
|
// 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)
|
|
|
|
|
if entry.pinned && !self.step_options.is_empty() {
|
|
|
|
|
let mut r = row![text(&entry.text).size(11).color(color)]
|
|
|
|
|
.spacing(6)
|
|
|
|
|
.align_y(iced::Center);
|
|
|
|
|
for opt in &self.step_options {
|
|
|
|
|
let btn = button(
|
|
|
|
|
text(opt.label.to_uppercase()).size(11).color(CMD_COLOR),
|
|
|
|
|
)
|
|
|
|
|
.on_press(Message::CommandOptionPick(opt.keyword.clone()))
|
|
|
|
|
.padding([1, 6])
|
|
|
|
|
.style(|_: &Theme, status| {
|
|
|
|
|
let bg = if matches!(status, button::Status::Hovered) {
|
|
|
|
|
Color {
|
|
|
|
|
r: 0.28,
|
|
|
|
|
g: 0.40,
|
|
|
|
|
b: 0.56,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
INPUT_ROW_BG
|
|
|
|
|
};
|
|
|
|
|
button::Style {
|
|
|
|
|
background: Some(Background::Color(bg)),
|
|
|
|
|
text_color: Color::WHITE,
|
|
|
|
|
border: Border {
|
|
|
|
|
color: BORDER_COLOR,
|
|
|
|
|
width: 1.0,
|
|
|
|
|
radius: 3.0.into(),
|
|
|
|
|
},
|
|
|
|
|
..Default::default()
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
r = r.push(btn);
|
|
|
|
|
}
|
|
|
|
|
col.push(container(r).padding([1, 8]))
|
|
|
|
|
} else {
|
|
|
|
|
col.push(container(text(&entry.text).size(11).color(color)).padding([1, 8]))
|
|
|
|
|
}
|
2026-05-26 19:28:09 +03:00
|
|
|
});
|
2026-06-09 18:46:45 +03:00
|
|
|
let prompt = container(text("Command:").size(11).color(PROMPT_COLOR)).padding([5, 8]);
|
feat(dyn-input): TAB-editable distance/angle/coordinate fields
Dinamik giriş artık cursor yanında düzenlenebilir kutular gösteriyor.
TAB ile kutular arası geçilir, bir kutuya yazınca o kutu kilitlenir
(değer sabitlenir), diğerleri cursor'ı takip etmeye devam eder, Enter
ile çözülen nokta komuta point-pick olarak iletilir.
- DocumentTab.dyn_fields / dyn_active state; DynComponent + DynFieldEntry
- sync_dyn_fields komutun dyn_field() + base noktasına göre kutu kümesini
türetir: nokta+base → [d, açı], nokta+base yok → [X, Y], radius → [d],
açı → [açı]
- klavye yönlendirme: rakam/./-/+ aktif kutuya, TAB sonraki kutuya,
Enter try_dyn_commit, Backspace kilidi açar
- dyn_capturing iken command-line text alanı on_input'suz bırakılıyor
ki focus'u ve tuşları çalmasın; girişler global key aboneliğinden
geliyor (numpad dahil text alanı üzerinden yakalanıyor)
- çoklu-kutu overlay render: aktif kutu vurgulu, kilitli kutu farklı
kenar rengi
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:08:57 +03:00
|
|
|
// 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
|
2026-03-22 14:56:27 +03:00
|
|
|
.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,
|
2026-05-26 19:23:19 +03:00
|
|
|
radius: 2.0.into(),
|
2026-03-22 14:56:27 +03:00
|
|
|
},
|
|
|
|
|
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]);
|
2026-05-26 22:45:54 +03:00
|
|
|
// 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 {
|
2026-06-27 14:45:51 +03:00
|
|
|
// Before any arrow-key navigation, the top match is
|
|
|
|
|
// highlighted so it's visible that Enter runs it — the
|
|
|
|
|
// standard DWG command-line behavior.
|
|
|
|
|
let cursor = self.autocomplete_cursor.unwrap_or(0);
|
2026-05-26 22:45:54 +03:00
|
|
|
let mut col = column![].spacing(0).width(Length::Fill);
|
|
|
|
|
for (idx, cmd) in matches.iter().enumerate() {
|
2026-06-27 14:45:51 +03:00
|
|
|
let is_selected = cursor == idx;
|
2026-07-04 23:01:53 +03:00
|
|
|
let row = button(text(cmd.clone()).size(11).color(CMD_COLOR))
|
|
|
|
|
.on_press(Message::CommandSuggestionPick(cmd.clone()))
|
2026-05-26 22:45:54 +03:00
|
|
|
.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()
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-26 22:13:15 +03:00
|
|
|
// 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.
|
2026-06-18 12:29:32 +03:00
|
|
|
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)
|
2026-05-26 22:13:15 +03:00
|
|
|
.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);
|
|
|
|
|
|
2026-07-01 18:17:46 +03:00
|
|
|
// Full backlog dropdown — appears ABOVE the input pill when open. The
|
|
|
|
|
// whole log is rendered in ONE read-only `text_editor` (backed by
|
|
|
|
|
// `history_content`) rather than per-line labels, so a single mouse
|
|
|
|
|
// drag selects across lines and Ctrl+C copies the lot — issue #232.
|
|
|
|
|
// Edits are dropped in the update handler, keeping it read-only. The
|
|
|
|
|
// editor scrolls internally past `max_height`; `opaque` stops its
|
|
|
|
|
// mouse-wheel events bubbling to the viewport shader behind it (else
|
|
|
|
|
// scrolling the history zoomed the drawing).
|
|
|
|
|
let dropdown: Element<'a, Message> = if self.history_open {
|
|
|
|
|
let log = text_editor(history_content)
|
|
|
|
|
.on_action(Message::CommandHistoryEdit)
|
|
|
|
|
.size(11)
|
|
|
|
|
.padding([2, 8])
|
|
|
|
|
.max_height(180.0)
|
|
|
|
|
.style(|_: &Theme, _status| text_editor::Style {
|
|
|
|
|
background: Background::Color(PANEL_BG),
|
|
|
|
|
border: Border::default(),
|
|
|
|
|
placeholder: OUT_COLOR,
|
|
|
|
|
value: CMD_COLOR,
|
|
|
|
|
selection: Color {
|
|
|
|
|
r: 0.20,
|
|
|
|
|
g: 0.44,
|
|
|
|
|
b: 0.72,
|
|
|
|
|
a: 0.5,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
// Header strip: a Copy-all and a Clear button pinned above the log.
|
|
|
|
|
let copy_btn = button(
|
|
|
|
|
row![
|
|
|
|
|
crate::ui::icons::tinted(crate::ui::icons::COPY, 11.0, PROMPT_COLOR),
|
|
|
|
|
text("Copy").size(11).color(CMD_COLOR),
|
|
|
|
|
]
|
|
|
|
|
.spacing(4)
|
|
|
|
|
.align_y(iced::Center),
|
|
|
|
|
)
|
|
|
|
|
.on_press(Message::CommandHistoryCopy)
|
|
|
|
|
.style(header_btn_style)
|
|
|
|
|
.padding([2, 6]);
|
|
|
|
|
let clear_btn = button(
|
|
|
|
|
row![
|
|
|
|
|
crate::ui::icons::tinted(crate::ui::icons::TRASH, 11.0, ERR_COLOR),
|
|
|
|
|
text("Clear").size(11).color(CMD_COLOR),
|
|
|
|
|
]
|
|
|
|
|
.spacing(4)
|
|
|
|
|
.align_y(iced::Center),
|
2026-05-26 22:13:15 +03:00
|
|
|
)
|
2026-07-01 18:17:46 +03:00
|
|
|
.on_press(Message::CommandHistoryClear)
|
|
|
|
|
.style(header_btn_style)
|
|
|
|
|
.padding([2, 6]);
|
|
|
|
|
let header = container(
|
|
|
|
|
row![Space::new().width(Length::Fill), copy_btn, clear_btn]
|
|
|
|
|
.spacing(6)
|
|
|
|
|
.align_y(iced::Center),
|
|
|
|
|
)
|
|
|
|
|
.width(Length::Fill)
|
|
|
|
|
.padding([2, 6]);
|
|
|
|
|
let panel = container(column![header, log])
|
2026-05-26 22:13:15 +03:00
|
|
|
.style(|_: &Theme| container::Style {
|
|
|
|
|
background: Some(Background::Color(PANEL_BG)),
|
|
|
|
|
border: Border {
|
|
|
|
|
color: BORDER_COLOR,
|
|
|
|
|
width: 1.0,
|
|
|
|
|
radius: 4.0.into(),
|
|
|
|
|
},
|
|
|
|
|
..Default::default()
|
|
|
|
|
})
|
|
|
|
|
.width(Length::Fill)
|
|
|
|
|
.padding([4, 0]);
|
|
|
|
|
opaque(panel).into()
|
|
|
|
|
} else {
|
|
|
|
|
container(column![]).height(0).into()
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
container(column![
|
2026-05-26 22:45:54 +03:00
|
|
|
autocomplete,
|
2026-05-26 22:13:15 +03:00
|
|
|
dropdown,
|
2026-03-22 14:56:27 +03:00
|
|
|
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,
|
2026-05-26 19:23:19 +03:00
|
|
|
radius: 3.0.into()
|
2026-03-22 14:56:27 +03:00
|
|
|
},
|
|
|
|
|
..Default::default()
|
|
|
|
|
})
|
2026-07-01 09:33:00 +03:00
|
|
|
.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)),
|
2026-03-22 14:56:27 +03:00
|
|
|
])
|
|
|
|
|
.style(|_: &Theme| container::Style {
|
|
|
|
|
background: Some(Background::Color(PANEL_BG)),
|
|
|
|
|
border: Border {
|
|
|
|
|
color: BORDER_COLOR,
|
|
|
|
|
width: 1.0,
|
2026-05-26 19:23:19 +03:00
|
|
|
radius: 4.0.into(),
|
2026-03-22 14:56:27 +03:00
|
|
|
},
|
|
|
|
|
..Default::default()
|
|
|
|
|
})
|
2026-05-26 19:23:19 +03:00
|
|
|
.width(Length::Fixed(720.0))
|
2026-03-22 14:56:27 +03:00
|
|
|
.into()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 14:45:51 +03:00
|
|
|
/// 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.
|
|
|
|
|
///
|
2026-07-04 23:01:53 +03:00
|
|
|
/// Names come from `crate::command::all_registered_command_names()` — the
|
|
|
|
|
/// compile-time `inventory` registry — merged with `dynamic`, the command names
|
|
|
|
|
/// contributed by loaded plugins (runtime, so they can't be `&'static`; see
|
|
|
|
|
/// #272). Returns owned strings to carry both sources.
|
|
|
|
|
pub fn ranked_matches(needle: &str, dynamic: &[String]) -> Vec<String> {
|
2026-06-27 14:45:51 +03:00
|
|
|
let needle = needle.trim().to_uppercase();
|
|
|
|
|
if needle.is_empty() {
|
|
|
|
|
return Vec::new();
|
|
|
|
|
}
|
2026-07-04 23:01:53 +03:00
|
|
|
let mut matches: Vec<String> = crate::command::all_registered_command_names()
|
2026-06-27 14:45:51 +03:00
|
|
|
.into_iter()
|
2026-07-04 23:01:53 +03:00
|
|
|
.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()))
|
2026-06-27 14:45:51 +03:00
|
|
|
.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.
|
2026-07-04 23:01:53 +03:00
|
|
|
matches.sort_by(|a, b| {
|
|
|
|
|
(!a.starts_with(&needle))
|
|
|
|
|
.cmp(&!b.starts_with(&needle))
|
|
|
|
|
.then_with(|| a.cmp(b))
|
|
|
|
|
});
|
2026-06-27 14:45:51 +03:00
|
|
|
matches.truncate(AUTOCOMPLETE_LIMIT);
|
|
|
|
|
matches
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-01 18:17:46 +03:00
|
|
|
/// Flat button style for the history dropdown's Copy / Clear strip: a subtle
|
|
|
|
|
/// filled pill that brightens on hover.
|
|
|
|
|
fn header_btn_style(_: &Theme, status: button::Status) -> button::Style {
|
|
|
|
|
let bg = if matches!(status, button::Status::Hovered) {
|
|
|
|
|
Color {
|
|
|
|
|
r: 0.24,
|
|
|
|
|
g: 0.24,
|
|
|
|
|
b: 0.24,
|
|
|
|
|
a: 1.0,
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
INPUT_ROW_BG
|
|
|
|
|
};
|
|
|
|
|
button::Style {
|
|
|
|
|
background: Some(Background::Color(bg)),
|
|
|
|
|
text_color: Color::WHITE,
|
|
|
|
|
border: Border {
|
|
|
|
|
color: BORDER_COLOR,
|
|
|
|
|
width: 1.0,
|
|
|
|
|
radius: 3.0.into(),
|
|
|
|
|
},
|
|
|
|
|
..Default::default()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
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,
|
|
|
|
|
};
|
2026-05-26 22:45:54 +03:00
|
|
|
|
2026-07-04 23:01:53 +03:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::ranked_matches;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn dynamic_plugin_commands_surface_in_autocomplete() {
|
|
|
|
|
// No built-in command contains the plugin prefix, so an empty pool
|
|
|
|
|
// yields nothing — reproducing the #272 bug's blind autocomplete.
|
|
|
|
|
assert!(ranked_matches("LS_", &[]).is_empty());
|
|
|
|
|
// A loaded plugin's commands become typeable, case-insensitively, and
|
|
|
|
|
// the prefix substring surfaces every one of them.
|
|
|
|
|
let pool = vec!["LS_LABEL".to_string(), "ls_autolabel".to_string()];
|
|
|
|
|
let m = ranked_matches("LS_", &pool);
|
|
|
|
|
assert!(m.contains(&"LS_LABEL".to_string()), "got {m:?}");
|
|
|
|
|
assert!(m.contains(&"LS_AUTOLABEL".to_string()), "got {m:?}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn builtin_commands_still_match_with_an_empty_pool() {
|
|
|
|
|
let m = ranked_matches("LINE", &[]);
|
|
|
|
|
assert!(m.iter().any(|c| c == "LINE"), "got {m:?}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|