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::{
|
2026-07-29 22:53:59 +03:00
|
|
|
button, column, container, opaque, row, rule, text, text_editor, text_input, tooltip, Space,
|
2026-07-01 18:17:46 +03:00
|
|
|
};
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 22:15:33 +03:00
|
|
|
/// 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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
const MAX_HISTORY: usize = 64;
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Default)]
|
|
|
|
|
pub struct CommandLine {
|
|
|
|
|
pub input: String,
|
2026-07-21 19:43:11 +03:00
|
|
|
/// 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,
|
2026-03-22 14:56:27 +03:00
|
|
|
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>,
|
feat(alias): centralize command aliases in user-editable ocad.pgp (#286/#288)
Command aliases (L→LINE, CC→COPYCLIP) were scattered across dispatch match
arms and CommandRegistration name arrays. Move them all into a single
user-editable file:
- assets/ocad.pgp: shipped default alias table (ALIAS,*COMMAND format),
embedded and copied to config/ocad.pgp on first launch; only that user
copy is read after. AA→AREA default closes #286.
- resolve_alias() rewrites the leading command token at the top of
dispatch_command_inner, so aliases resolve for every path (families,
plugins, Repeat menu). Args after the first space are untouched.
- Autocomplete hides alias keys and, when the whole input is an alias,
surfaces its target command as the top (highlighted) suggestion so the
highlight matches what Enter runs (AA→AREA, L→LINE).
- ALIASEDIT opens a new editor modal (add/edit/delete rows) with an Apply
button; Apply persists to ocad.pgp, closing discards unapplied edits.
- Strip alias tokens from all 8 dispatch match-arm files and 71 module
CommandRegistration arrays, keeping canonical/synonym/distinct-command
names. ocad.pgp is now the sole source: deleting or remapping an alias
there takes effect. CUI stays on the keyboard-shortcut panel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:39:22 +03:00
|
|
|
/// 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>,
|
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}"));
|
|
|
|
|
}
|
2026-07-27 13:13:58 +03:00
|
|
|
/// 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) {
|
|
|
|
|
let text = format!("*Invalid* {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);
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
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> {
|
feat(alias): centralize command aliases in user-editable ocad.pgp (#286/#288)
Command aliases (L→LINE, CC→COPYCLIP) were scattered across dispatch match
arms and CommandRegistration name arrays. Move them all into a single
user-editable file:
- assets/ocad.pgp: shipped default alias table (ALIAS,*COMMAND format),
embedded and copied to config/ocad.pgp on first launch; only that user
copy is read after. AA→AREA default closes #286.
- resolve_alias() rewrites the leading command token at the top of
dispatch_command_inner, so aliases resolve for every path (families,
plugins, Repeat menu). Args after the first space are untouched.
- Autocomplete hides alias keys and, when the whole input is an alias,
surfaces its target command as the top (highlighted) suggestion so the
highlight matches what Enter runs (AA→AREA, L→LINE).
- ALIASEDIT opens a new editor modal (add/edit/delete rows) with an Apply
button; Apply persists to ocad.pgp, closing discards unapplied edits.
- Strip alias tokens from all 8 dispatch match-arm files and 71 module
CommandRegistration arrays, keeping canonical/synonym/distinct-command
names. ocad.pgp is now the sole source: deleting or remapping an alias
there takes effect. CUI stays on the keyboard-shortcut panel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:39:22 +03:00
|
|
|
ranked_matches(
|
|
|
|
|
self.input.trim(),
|
|
|
|
|
&self.dynamic_commands,
|
|
|
|
|
&self.command_aliases,
|
|
|
|
|
)
|
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.
|
2026-07-29 22:53:59 +03:00
|
|
|
let mut visible: Vec<&HistoryEntry> = self
|
2026-05-26 19:28:09 +03:00
|
|
|
.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();
|
2026-07-29 22:53:59 +03:00
|
|
|
// 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);
|
|
|
|
|
}
|
2026-05-26 19:28:09 +03:00
|
|
|
let start = visible.len().saturating_sub(4);
|
|
|
|
|
let history_rows = visible[start..]
|
|
|
|
|
.iter()
|
|
|
|
|
.fold(column![].spacing(0), |col, entry| {
|
2026-07-28 09:33:12 +03:00
|
|
|
let kind = entry.kind.clone();
|
|
|
|
|
let entry_text = |value: String| {
|
|
|
|
|
text(value).size(11).style({
|
|
|
|
|
let kind = kind.clone();
|
|
|
|
|
move |theme: &Theme| iced::widget::text::Style {
|
|
|
|
|
color: Some(history_color(theme, &kind)),
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-05-26 19:28:09 +03:00
|
|
|
};
|
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
|
2026-07-21 22:15:33 +03:00
|
|
|
// 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).
|
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
|
|
|
if entry.pinned && !self.step_options.is_empty() {
|
2026-07-21 22:15:33 +03:00
|
|
|
let shown = strip_option_listing(&entry.text);
|
2026-07-28 09:33:12 +03:00
|
|
|
let mut r = row![entry_text(shown)]
|
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
|
|
|
.spacing(6)
|
|
|
|
|
.align_y(iced::Center);
|
|
|
|
|
for opt in &self.step_options {
|
2026-07-28 09:33:12 +03:00
|
|
|
let btn = button(text(opt.label.to_uppercase()).size(11))
|
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
|
|
|
.on_press(Message::CommandOptionPick(opt.keyword.clone()))
|
|
|
|
|
.padding([1, 6])
|
2026-07-28 09:33:12 +03:00
|
|
|
.style(|theme: &Theme, status| {
|
|
|
|
|
let palette = theme.extended_palette();
|
|
|
|
|
let pair = if matches!(
|
|
|
|
|
status,
|
|
|
|
|
button::Status::Hovered | button::Status::Pressed
|
|
|
|
|
) {
|
|
|
|
|
palette.primary.weak
|
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
|
|
|
} else {
|
2026-07-28 09:33:12 +03:00
|
|
|
palette.background.weakest
|
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
|
|
|
};
|
|
|
|
|
button::Style {
|
2026-07-28 09:33:12 +03:00
|
|
|
background: Some(Background::Color(pair.color)),
|
|
|
|
|
text_color: pair.text,
|
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
|
|
|
border: Border {
|
2026-07-28 09:33:12 +03:00
|
|
|
color: palette.background.neutral.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
|
|
|
width: 1.0,
|
|
|
|
|
radius: 3.0.into(),
|
|
|
|
|
},
|
|
|
|
|
..Default::default()
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
r = r.push(btn);
|
|
|
|
|
}
|
|
|
|
|
col.push(container(r).padding([1, 8]))
|
|
|
|
|
} else {
|
2026-07-28 09:33:12 +03:00
|
|
|
col.push(container(entry_text(entry.text.clone())).padding([1, 8]))
|
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
|
|
|
}
|
2026-05-26 19:28:09 +03:00
|
|
|
});
|
2026-07-28 09:33:12 +03:00
|
|
|
let prompt = container(
|
|
|
|
|
text("Command:").size(11).style(|theme: &Theme| iced::widget::text::Style {
|
|
|
|
|
color: Some(theme.extended_palette().success.base.color),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.padding([5, 8]);
|
2026-07-21 19:43:11 +03:00
|
|
|
// 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('>');
|
2026-07-28 09:33:12 +03:00
|
|
|
let literal_btn = button(text(">").size(11))
|
2026-07-21 19:43:11 +03:00
|
|
|
.on_press(Message::CommandLiteralToggle)
|
|
|
|
|
.padding([2, 6])
|
2026-07-28 09:33:12 +03:00
|
|
|
.style(move |theme: &Theme, status| {
|
|
|
|
|
let palette = theme.extended_palette();
|
|
|
|
|
let pair = if literal_active {
|
|
|
|
|
palette.primary.weak
|
|
|
|
|
} else if matches!(
|
|
|
|
|
status,
|
|
|
|
|
button::Status::Hovered | button::Status::Pressed
|
|
|
|
|
) {
|
|
|
|
|
palette.background.weak
|
2026-07-21 19:43:11 +03:00
|
|
|
} else {
|
2026-07-28 09:33:12 +03:00
|
|
|
palette.background.weakest
|
2026-07-21 19:43:11 +03:00
|
|
|
};
|
|
|
|
|
button::Style {
|
2026-07-28 09:33:12 +03:00
|
|
|
background: Some(Background::Color(pair.color)),
|
|
|
|
|
text_color: pair.text,
|
2026-07-21 19:43:11 +03:00
|
|
|
border: Border {
|
2026-07-28 09:33:12 +03:00
|
|
|
color: palette.background.neutral.color,
|
2026-07-21 19:43:11 +03:00
|
|
|
width: 1.0,
|
|
|
|
|
radius: 3.0.into(),
|
|
|
|
|
},
|
|
|
|
|
..Default::default()
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
let literal_tip = container(
|
|
|
|
|
text("Literal spaces: Space stays in the line instead of running the command (same as typing a leading '>'). Stays on until toggled off.")
|
2026-07-28 09:33:12 +03:00
|
|
|
.size(11),
|
2026-07-21 19:43:11 +03:00
|
|
|
)
|
|
|
|
|
.padding([3, 6])
|
2026-07-28 09:33:12 +03:00
|
|
|
.style(container::bordered_box);
|
2026-07-21 19:43:11 +03:00
|
|
|
let literal_btn = tooltip(literal_btn, literal_tip, tooltip::Position::Top).gap(4);
|
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
|
|
|
.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-28 09:33:12 +03:00
|
|
|
let row = button(text(cmd.clone()).size(11))
|
2026-07-04 23:01:53 +03:00
|
|
|
.on_press(Message::CommandSuggestionPick(cmd.clone()))
|
2026-05-26 22:45:54 +03:00
|
|
|
.width(Length::Fill)
|
|
|
|
|
.padding([2, 8])
|
2026-07-28 09:33:12 +03:00
|
|
|
.style(move |theme: &Theme, status| {
|
|
|
|
|
let palette = theme.extended_palette();
|
|
|
|
|
let pair = if is_selected {
|
|
|
|
|
palette.primary.weak
|
|
|
|
|
} else if matches!(
|
|
|
|
|
status,
|
|
|
|
|
button::Status::Hovered | button::Status::Pressed
|
|
|
|
|
) {
|
|
|
|
|
palette.background.weak
|
2026-05-26 22:45:54 +03:00
|
|
|
} else {
|
2026-07-28 09:33:12 +03:00
|
|
|
palette.background.base
|
2026-05-26 22:45:54 +03:00
|
|
|
};
|
|
|
|
|
button::Style {
|
2026-07-28 09:33:12 +03:00
|
|
|
background: Some(Background::Color(pair.color)),
|
|
|
|
|
text_color: pair.text,
|
2026-05-26 22:45:54 +03:00
|
|
|
border: Border::default(),
|
|
|
|
|
..Default::default()
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
col = col.push(row);
|
|
|
|
|
}
|
|
|
|
|
container(col)
|
2026-07-28 09:33:12 +03:00
|
|
|
.style(container::bordered_box)
|
2026-05-26 22:45:54 +03:00
|
|
|
.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 {
|
2026-07-28 09:33:12 +03:00
|
|
|
crate::ui::icons::themed_arrow_down(11.0)
|
2026-06-18 12:29:32 +03:00
|
|
|
} else {
|
2026-07-28 09:33:12 +03:00
|
|
|
crate::ui::icons::themed_arrow_right(11.0)
|
2026-06-18 12:29:32 +03:00
|
|
|
};
|
|
|
|
|
let dropdown_btn = button(dropdown_icon)
|
|
|
|
|
.on_press(Message::CommandHistoryToggle)
|
2026-07-28 09:33:12 +03:00
|
|
|
.style(button::subtle)
|
2026-05-26 22:13:15 +03:00
|
|
|
.padding([2, 6]);
|
2026-07-21 19:43:11 +03:00
|
|
|
let input_row = row![prompt, literal_btn, input, dropdown_btn]
|
2026-05-26 22:13:15 +03:00
|
|
|
.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)
|
2026-07-28 09:33:12 +03:00
|
|
|
.style(|theme: &Theme, _status| {
|
|
|
|
|
let palette = theme.extended_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),
|
|
|
|
|
}
|
2026-07-01 18:17:46 +03:00
|
|
|
});
|
|
|
|
|
// Header strip: a Copy-all and a Clear button pinned above the log.
|
|
|
|
|
let copy_btn = button(
|
|
|
|
|
row![
|
2026-07-28 09:33:12 +03:00
|
|
|
crate::ui::icons::themed_success(crate::ui::icons::COPY, 11.0),
|
|
|
|
|
text("Copy").size(11),
|
2026-07-01 18:17:46 +03:00
|
|
|
]
|
|
|
|
|
.spacing(4)
|
|
|
|
|
.align_y(iced::Center),
|
|
|
|
|
)
|
|
|
|
|
.on_press(Message::CommandHistoryCopy)
|
|
|
|
|
.style(header_btn_style)
|
|
|
|
|
.padding([2, 6]);
|
|
|
|
|
let clear_btn = button(
|
|
|
|
|
row![
|
2026-07-28 09:33:12 +03:00
|
|
|
crate::ui::icons::themed_warning(crate::ui::icons::TRASH, 11.0),
|
|
|
|
|
text("Clear").size(11),
|
2026-07-01 18:17:46 +03:00
|
|
|
]
|
|
|
|
|
.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
|
|
|
.width(Length::Fill)
|
|
|
|
|
.padding([4, 0]);
|
|
|
|
|
opaque(panel).into()
|
|
|
|
|
} else {
|
|
|
|
|
container(column![]).height(0).into()
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-29 22:53:59 +03:00
|
|
|
// 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 {
|
|
|
|
|
container(column![]).height(0).into()
|
|
|
|
|
} else {
|
2026-03-22 14:56:27 +03:00
|
|
|
container(history_rows)
|
2026-07-28 09:33:12 +03:00
|
|
|
.style(|theme: &Theme| container::Style {
|
|
|
|
|
background: Some(Background::Color(
|
|
|
|
|
theme.extended_palette().background.base.color,
|
|
|
|
|
)),
|
2026-03-22 14:56:27 +03:00
|
|
|
..Default::default()
|
|
|
|
|
})
|
|
|
|
|
.width(Length::Fill)
|
2026-07-29 22:53:59 +03:00
|
|
|
.padding([2, 0])
|
|
|
|
|
.into()
|
|
|
|
|
};
|
|
|
|
|
let history_divider: Element<'a, Message> = if self.history_open {
|
|
|
|
|
rule::horizontal(1).into()
|
|
|
|
|
} else {
|
|
|
|
|
container(column![]).height(0).into()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
container(column![
|
|
|
|
|
autocomplete,
|
|
|
|
|
dropdown,
|
|
|
|
|
recent_history,
|
|
|
|
|
history_divider,
|
2026-03-22 14:56:27 +03:00
|
|
|
container(input_row)
|
2026-07-28 09:33:12 +03:00
|
|
|
.style(|theme: &Theme| {
|
|
|
|
|
let palette = theme.extended_palette();
|
|
|
|
|
container::Style {
|
|
|
|
|
background: Some(Background::Color(palette.background.weakest.color)),
|
2026-03-22 14:56:27 +03:00
|
|
|
..Default::default()
|
2026-07-28 09:33:12 +03:00
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
})
|
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
|
|
|
])
|
2026-07-28 09:33:12 +03:00
|
|
|
.style(|theme: &Theme| {
|
|
|
|
|
let palette = theme.extended_palette();
|
|
|
|
|
container::Style {
|
|
|
|
|
background: Some(Background::Color(palette.background.base.color)),
|
2026-03-22 14:56:27 +03:00
|
|
|
border: Border {
|
2026-07-28 09:33:12 +03:00
|
|
|
color: palette.background.neutral.color,
|
2026-03-22 14:56:27 +03:00
|
|
|
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-07-28 09:33:12 +03:00
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
})
|
2026-07-29 22:53:59 +03:00
|
|
|
.width(Length::Fill)
|
|
|
|
|
.max_width(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.
|
feat(alias): centralize command aliases in user-editable ocad.pgp (#286/#288)
Command aliases (L→LINE, CC→COPYCLIP) were scattered across dispatch match
arms and CommandRegistration name arrays. Move them all into a single
user-editable file:
- assets/ocad.pgp: shipped default alias table (ALIAS,*COMMAND format),
embedded and copied to config/ocad.pgp on first launch; only that user
copy is read after. AA→AREA default closes #286.
- resolve_alias() rewrites the leading command token at the top of
dispatch_command_inner, so aliases resolve for every path (families,
plugins, Repeat menu). Args after the first space are untouched.
- Autocomplete hides alias keys and, when the whole input is an alias,
surfaces its target command as the top (highlighted) suggestion so the
highlight matches what Enter runs (AA→AREA, L→LINE).
- ALIASEDIT opens a new editor modal (add/edit/delete rows) with an Apply
button; Apply persists to ocad.pgp, closing discards unapplied edits.
- Strip alias tokens from all 8 dispatch match-arm files and 71 module
CommandRegistration arrays, keeping canonical/synonym/distinct-command
names. ocad.pgp is now the sole source: deleting or remapping an alias
there takes effect. CUI stays on the keyboard-shortcut panel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:39:22 +03:00
|
|
|
///
|
|
|
|
|
/// `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> {
|
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()))
|
feat(alias): centralize command aliases in user-editable ocad.pgp (#286/#288)
Command aliases (L→LINE, CC→COPYCLIP) were scattered across dispatch match
arms and CommandRegistration name arrays. Move them all into a single
user-editable file:
- assets/ocad.pgp: shipped default alias table (ALIAS,*COMMAND format),
embedded and copied to config/ocad.pgp on first launch; only that user
copy is read after. AA→AREA default closes #286.
- resolve_alias() rewrites the leading command token at the top of
dispatch_command_inner, so aliases resolve for every path (families,
plugins, Repeat menu). Args after the first space are untouched.
- Autocomplete hides alias keys and, when the whole input is an alias,
surfaces its target command as the top (highlighted) suggestion so the
highlight matches what Enter runs (AA→AREA, L→LINE).
- ALIASEDIT opens a new editor modal (add/edit/delete rows) with an Apply
button; Apply persists to ocad.pgp, closing discards unapplied edits.
- Strip alias tokens from all 8 dispatch match-arm files and 71 module
CommandRegistration arrays, keeping canonical/synonym/distinct-command
names. ocad.pgp is now the sole source: deleting or remapping an alias
there takes effect. CUI stays on the keyboard-shortcut panel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:39:22 +03:00
|
|
|
// Hide aliases (keys of the table); their target command still shows.
|
|
|
|
|
.filter(|cmd| cmd.contains(&needle) && !aliases.contains_key(cmd))
|
2026-06-27 14:45:51 +03:00
|
|
|
.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))
|
|
|
|
|
});
|
feat(alias): centralize command aliases in user-editable ocad.pgp (#286/#288)
Command aliases (L→LINE, CC→COPYCLIP) were scattered across dispatch match
arms and CommandRegistration name arrays. Move them all into a single
user-editable file:
- assets/ocad.pgp: shipped default alias table (ALIAS,*COMMAND format),
embedded and copied to config/ocad.pgp on first launch; only that user
copy is read after. AA→AREA default closes #286.
- resolve_alias() rewrites the leading command token at the top of
dispatch_command_inner, so aliases resolve for every path (families,
plugins, Repeat menu). Args after the first space are untouched.
- Autocomplete hides alias keys and, when the whole input is an alias,
surfaces its target command as the top (highlighted) suggestion so the
highlight matches what Enter runs (AA→AREA, L→LINE).
- ALIASEDIT opens a new editor modal (add/edit/delete rows) with an Apply
button; Apply persists to ocad.pgp, closing discards unapplied edits.
- Strip alias tokens from all 8 dispatch match-arm files and 71 module
CommandRegistration arrays, keeping canonical/synonym/distinct-command
names. ocad.pgp is now the sole source: deleting or remapping an alias
there takes effect. CUI stays on the keyboard-shortcut panel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:39:22 +03:00
|
|
|
// 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());
|
|
|
|
|
}
|
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.
|
2026-07-28 09:33:12 +03:00
|
|
|
fn header_btn_style(theme: &Theme, status: button::Status) -> button::Style {
|
|
|
|
|
let palette = theme.extended_palette();
|
|
|
|
|
let pair = if matches!(status, button::Status::Hovered | button::Status::Pressed) {
|
|
|
|
|
palette.background.weak
|
2026-07-01 18:17:46 +03:00
|
|
|
} else {
|
2026-07-28 09:33:12 +03:00
|
|
|
palette.background.weakest
|
2026-07-01 18:17:46 +03:00
|
|
|
};
|
|
|
|
|
button::Style {
|
2026-07-28 09:33:12 +03:00
|
|
|
background: Some(Background::Color(pair.color)),
|
|
|
|
|
text_color: pair.text,
|
2026-07-01 18:17:46 +03:00
|
|
|
border: Border {
|
2026-07-28 09:33:12 +03:00
|
|
|
color: palette.background.neutral.color,
|
2026-07-01 18:17:46 +03:00
|
|
|
width: 1.0,
|
|
|
|
|
radius: 3.0.into(),
|
|
|
|
|
},
|
|
|
|
|
..Default::default()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 09:33:12 +03:00
|
|
|
fn history_color(theme: &Theme, kind: &EntryKind) -> Color {
|
|
|
|
|
let palette = theme.extended_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 => palette.primary.base.color,
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-26 22:45:54 +03:00
|
|
|
|
2026-07-04 23:01:53 +03:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
2026-07-27 13:13:58 +03:00
|
|
|
use super::{ranked_matches, CommandLine};
|
feat(alias): centralize command aliases in user-editable ocad.pgp (#286/#288)
Command aliases (L→LINE, CC→COPYCLIP) were scattered across dispatch match
arms and CommandRegistration name arrays. Move them all into a single
user-editable file:
- assets/ocad.pgp: shipped default alias table (ALIAS,*COMMAND format),
embedded and copied to config/ocad.pgp on first launch; only that user
copy is read after. AA→AREA default closes #286.
- resolve_alias() rewrites the leading command token at the top of
dispatch_command_inner, so aliases resolve for every path (families,
plugins, Repeat menu). Args after the first space are untouched.
- Autocomplete hides alias keys and, when the whole input is an alias,
surfaces its target command as the top (highlighted) suggestion so the
highlight matches what Enter runs (AA→AREA, L→LINE).
- ALIASEDIT opens a new editor modal (add/edit/delete rows) with an Apply
button; Apply persists to ocad.pgp, closing discards unapplied edits.
- Strip alias tokens from all 8 dispatch match-arm files and 71 module
CommandRegistration arrays, keeping canonical/synonym/distinct-command
names. ocad.pgp is now the sole source: deleting or remapping an alias
there takes effect. CUI stays on the keyboard-shortcut panel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:39:22 +03:00
|
|
|
use rustc_hash::FxHashMap;
|
|
|
|
|
|
|
|
|
|
fn aliases(pairs: &[(&str, &str)]) -> FxHashMap<String, String> {
|
|
|
|
|
pairs
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|(a, c)| (a.to_string(), c.to_string()))
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
2026-07-04 23:01:53 +03:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn dynamic_plugin_commands_surface_in_autocomplete() {
|
feat(alias): centralize command aliases in user-editable ocad.pgp (#286/#288)
Command aliases (L→LINE, CC→COPYCLIP) were scattered across dispatch match
arms and CommandRegistration name arrays. Move them all into a single
user-editable file:
- assets/ocad.pgp: shipped default alias table (ALIAS,*COMMAND format),
embedded and copied to config/ocad.pgp on first launch; only that user
copy is read after. AA→AREA default closes #286.
- resolve_alias() rewrites the leading command token at the top of
dispatch_command_inner, so aliases resolve for every path (families,
plugins, Repeat menu). Args after the first space are untouched.
- Autocomplete hides alias keys and, when the whole input is an alias,
surfaces its target command as the top (highlighted) suggestion so the
highlight matches what Enter runs (AA→AREA, L→LINE).
- ALIASEDIT opens a new editor modal (add/edit/delete rows) with an Apply
button; Apply persists to ocad.pgp, closing discards unapplied edits.
- Strip alias tokens from all 8 dispatch match-arm files and 71 module
CommandRegistration arrays, keeping canonical/synonym/distinct-command
names. ocad.pgp is now the sole source: deleting or remapping an alias
there takes effect. CUI stays on the keyboard-shortcut panel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:39:22 +03:00
|
|
|
let none = FxHashMap::default();
|
2026-07-04 23:01:53 +03:00
|
|
|
// No built-in command contains the plugin prefix, so an empty pool
|
|
|
|
|
// yields nothing — reproducing the #272 bug's blind autocomplete.
|
feat(alias): centralize command aliases in user-editable ocad.pgp (#286/#288)
Command aliases (L→LINE, CC→COPYCLIP) were scattered across dispatch match
arms and CommandRegistration name arrays. Move them all into a single
user-editable file:
- assets/ocad.pgp: shipped default alias table (ALIAS,*COMMAND format),
embedded and copied to config/ocad.pgp on first launch; only that user
copy is read after. AA→AREA default closes #286.
- resolve_alias() rewrites the leading command token at the top of
dispatch_command_inner, so aliases resolve for every path (families,
plugins, Repeat menu). Args after the first space are untouched.
- Autocomplete hides alias keys and, when the whole input is an alias,
surfaces its target command as the top (highlighted) suggestion so the
highlight matches what Enter runs (AA→AREA, L→LINE).
- ALIASEDIT opens a new editor modal (add/edit/delete rows) with an Apply
button; Apply persists to ocad.pgp, closing discards unapplied edits.
- Strip alias tokens from all 8 dispatch match-arm files and 71 module
CommandRegistration arrays, keeping canonical/synonym/distinct-command
names. ocad.pgp is now the sole source: deleting or remapping an alias
there takes effect. CUI stays on the keyboard-shortcut panel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:39:22 +03:00
|
|
|
assert!(ranked_matches("LS_", &[], &none).is_empty());
|
2026-07-04 23:01:53 +03:00
|
|
|
// 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()];
|
feat(alias): centralize command aliases in user-editable ocad.pgp (#286/#288)
Command aliases (L→LINE, CC→COPYCLIP) were scattered across dispatch match
arms and CommandRegistration name arrays. Move them all into a single
user-editable file:
- assets/ocad.pgp: shipped default alias table (ALIAS,*COMMAND format),
embedded and copied to config/ocad.pgp on first launch; only that user
copy is read after. AA→AREA default closes #286.
- resolve_alias() rewrites the leading command token at the top of
dispatch_command_inner, so aliases resolve for every path (families,
plugins, Repeat menu). Args after the first space are untouched.
- Autocomplete hides alias keys and, when the whole input is an alias,
surfaces its target command as the top (highlighted) suggestion so the
highlight matches what Enter runs (AA→AREA, L→LINE).
- ALIASEDIT opens a new editor modal (add/edit/delete rows) with an Apply
button; Apply persists to ocad.pgp, closing discards unapplied edits.
- Strip alias tokens from all 8 dispatch match-arm files and 71 module
CommandRegistration arrays, keeping canonical/synonym/distinct-command
names. ocad.pgp is now the sole source: deleting or remapping an alias
there takes effect. CUI stays on the keyboard-shortcut panel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:39:22 +03:00
|
|
|
let m = ranked_matches("LS_", &pool, &none);
|
2026-07-04 23:01:53 +03:00
|
|
|
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() {
|
feat(alias): centralize command aliases in user-editable ocad.pgp (#286/#288)
Command aliases (L→LINE, CC→COPYCLIP) were scattered across dispatch match
arms and CommandRegistration name arrays. Move them all into a single
user-editable file:
- assets/ocad.pgp: shipped default alias table (ALIAS,*COMMAND format),
embedded and copied to config/ocad.pgp on first launch; only that user
copy is read after. AA→AREA default closes #286.
- resolve_alias() rewrites the leading command token at the top of
dispatch_command_inner, so aliases resolve for every path (families,
plugins, Repeat menu). Args after the first space are untouched.
- Autocomplete hides alias keys and, when the whole input is an alias,
surfaces its target command as the top (highlighted) suggestion so the
highlight matches what Enter runs (AA→AREA, L→LINE).
- ALIASEDIT opens a new editor modal (add/edit/delete rows) with an Apply
button; Apply persists to ocad.pgp, closing discards unapplied edits.
- Strip alias tokens from all 8 dispatch match-arm files and 71 module
CommandRegistration arrays, keeping canonical/synonym/distinct-command
names. ocad.pgp is now the sole source: deleting or remapping an alias
there takes effect. CUI stays on the keyboard-shortcut panel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:39:22 +03:00
|
|
|
let m = ranked_matches("LINE", &[], &FxHashMap::default());
|
2026-07-04 23:01:53 +03:00
|
|
|
assert!(m.iter().any(|c| c == "LINE"), "got {m:?}");
|
|
|
|
|
}
|
feat(alias): centralize command aliases in user-editable ocad.pgp (#286/#288)
Command aliases (L→LINE, CC→COPYCLIP) were scattered across dispatch match
arms and CommandRegistration name arrays. Move them all into a single
user-editable file:
- assets/ocad.pgp: shipped default alias table (ALIAS,*COMMAND format),
embedded and copied to config/ocad.pgp on first launch; only that user
copy is read after. AA→AREA default closes #286.
- resolve_alias() rewrites the leading command token at the top of
dispatch_command_inner, so aliases resolve for every path (families,
plugins, Repeat menu). Args after the first space are untouched.
- Autocomplete hides alias keys and, when the whole input is an alias,
surfaces its target command as the top (highlighted) suggestion so the
highlight matches what Enter runs (AA→AREA, L→LINE).
- ALIASEDIT opens a new editor modal (add/edit/delete rows) with an Apply
button; Apply persists to ocad.pgp, closing discards unapplied edits.
- Strip alias tokens from all 8 dispatch match-arm files and 71 module
CommandRegistration arrays, keeping canonical/synonym/distinct-command
names. ocad.pgp is now the sole source: deleting or remapping an alias
there takes effect. CUI stays on the keyboard-shortcut panel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:39:22 +03:00
|
|
|
|
|
|
|
|
#[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:?}");
|
|
|
|
|
}
|
2026-07-04 23:01:53 +03:00
|
|
|
|
2026-07-27 13:13:58 +03:00
|
|
|
#[test]
|
|
|
|
|
fn issue_498_repeated_save_error_is_not_duplicated() {
|
|
|
|
|
let mut line = CommandLine::new();
|
|
|
|
|
let initial_len = line.history.len();
|
|
|
|
|
line.push_error_once("Unable to save: file is in use.");
|
|
|
|
|
line.push_error_once("Unable to save: file is in use.");
|
|
|
|
|
assert_eq!(line.history.len(), initial_len + 1);
|
|
|
|
|
}
|
|
|
|
|
}
|