forked from 1b-agent/opencadstudio-upstream
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 <[email protected]>
This commit is contained in:
1 parent
f5b8a7d993
commit
91ba9f7699
69 files changed
+767
-164
No files matched your search
+65
-6
@@ -50,6 +50,12 @@ pub struct CommandLine {
|
||||
/// compile-time command registry, so runtime plugin commands are typeable
|
||||
/// and discoverable — not only reachable via ribbon buttons (#272).
|
||||
pub dynamic_commands: Vec<String>,
|
||||
/// Command aliases (uppercase `alias → command`), a copy of the app's alias
|
||||
/// table kept here for autocomplete. Aliases are hidden from suggestions
|
||||
/// (a terse `CC` never appears) while their target command (`COPYCLIP`)
|
||||
/// still does; and when the whole input is an alias, its target is surfaced
|
||||
/// as the top suggestion so the highlight matches what Enter runs. (#288)
|
||||
pub command_aliases: rustc_hash::FxHashMap<String, String>,
|
||||
/// The active command step's prompt, mirrored here so a step change
|
||||
/// can be detected and the pinned (non-fading) history line updated.
|
||||
step_prompt: Option<String>,
|
||||
@@ -302,7 +308,11 @@ impl CommandLine {
|
||||
/// Autocomplete suggestions for the current input — see
|
||||
/// [`ranked_matches`]. Includes loaded plugins' commands (#272).
|
||||
pub fn autocomplete_matches(&self) -> Vec<String> {
|
||||
ranked_matches(self.input.trim(), &self.dynamic_commands)
|
||||
ranked_matches(
|
||||
self.input.trim(),
|
||||
&self.dynamic_commands,
|
||||
&self.command_aliases,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn view<'a>(
|
||||
@@ -625,7 +635,18 @@ impl CommandLine {
|
||||
/// 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> {
|
||||
///
|
||||
/// `aliases` is the `alias → command` table. Aliases themselves are dropped from
|
||||
/// the results, so a terse `CC` is typeable and dispatches (via the alias table)
|
||||
/// but never clutters the suggestions — only its target `COPYCLIP` is offered.
|
||||
/// And when the whole input is an alias, its target command is forced to the top
|
||||
/// so the highlighted suggestion matches what pressing Enter actually runs
|
||||
/// (typing `AA` highlights `AREA`, `L` highlights `LINE`). (#288)
|
||||
pub fn ranked_matches(
|
||||
needle: &str,
|
||||
dynamic: &[String],
|
||||
aliases: &rustc_hash::FxHashMap<String, String>,
|
||||
) -> Vec<String> {
|
||||
let needle = needle.trim().to_uppercase();
|
||||
if needle.is_empty() {
|
||||
return Vec::new();
|
||||
@@ -636,7 +657,8 @@ pub fn ranked_matches(needle: &str, dynamic: &[String]) -> Vec<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()))
|
||||
.filter(|cmd| cmd.contains(&needle))
|
||||
// Hide aliases (keys of the table); their target command still shows.
|
||||
.filter(|cmd| cmd.contains(&needle) && !aliases.contains_key(cmd))
|
||||
.collect();
|
||||
matches.sort();
|
||||
matches.dedup();
|
||||
@@ -647,6 +669,13 @@ pub fn ranked_matches(needle: &str, dynamic: &[String]) -> Vec<String> {
|
||||
.cmp(&!b.starts_with(&needle))
|
||||
.then_with(|| a.cmp(b))
|
||||
});
|
||||
// If the entire input is an alias, its target is what Enter runs, so make it
|
||||
// the first (highlighted) suggestion — even when the target has no substring
|
||||
// overlap with the alias (`AA` → `AREA`) and so wouldn't otherwise appear.
|
||||
if let Some(target) = aliases.get(&needle) {
|
||||
matches.retain(|m| m != target);
|
||||
matches.insert(0, target.clone());
|
||||
}
|
||||
matches.truncate(AUTOCOMPLETE_LIMIT);
|
||||
matches
|
||||
}
|
||||
@@ -740,24 +769,54 @@ const INFO_COLOR: Color = Color {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ranked_matches;
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
fn aliases(pairs: &[(&str, &str)]) -> FxHashMap<String, String> {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(a, c)| (a.to_string(), c.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_plugin_commands_surface_in_autocomplete() {
|
||||
let none = FxHashMap::default();
|
||||
// No built-in command contains the plugin prefix, so an empty pool
|
||||
// yields nothing — reproducing the #272 bug's blind autocomplete.
|
||||
assert!(ranked_matches("LS_", &[]).is_empty());
|
||||
assert!(ranked_matches("LS_", &[], &none).is_empty());
|
||||
// A loaded plugin's commands become typeable, case-insensitively, and
|
||||
// the prefix substring surfaces every one of them.
|
||||
let pool = vec!["LS_LABEL".to_string(), "ls_autolabel".to_string()];
|
||||
let m = ranked_matches("LS_", &pool);
|
||||
let m = ranked_matches("LS_", &pool, &none);
|
||||
assert!(m.contains(&"LS_LABEL".to_string()), "got {m:?}");
|
||||
assert!(m.contains(&"LS_AUTOLABEL".to_string()), "got {m:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_commands_still_match_with_an_empty_pool() {
|
||||
let m = ranked_matches("LINE", &[]);
|
||||
let m = ranked_matches("LINE", &[], &FxHashMap::default());
|
||||
assert!(m.iter().any(|c| c == "LINE"), "got {m:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aliases_are_hidden_but_their_command_survives() {
|
||||
// The alias `L` is dropped from suggestions while `LINE` (its target)
|
||||
// still appears. (#288)
|
||||
let a = aliases(&[("L", "LINE")]);
|
||||
let m = ranked_matches("L", &[], &a);
|
||||
assert!(!m.iter().any(|c| c == "L"), "alias L should be hidden: {m:?}");
|
||||
assert!(m.iter().any(|c| c == "LINE"), "LINE should remain: {m:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_alias_forces_its_target_to_the_top() {
|
||||
// Typing a full alias highlights the command Enter will run, even when
|
||||
// the target shares no substring with the alias (`AA` → `AREA`). (#288)
|
||||
let a = aliases(&[("AA", "AREA"), ("L", "LINE")]);
|
||||
let m = ranked_matches("AA", &[], &a);
|
||||
assert_eq!(m.first().map(String::as_str), Some("AREA"), "got {m:?}");
|
||||
let m = ranked_matches("L", &[], &a);
|
||||
assert_eq!(m.first().map(String::as_str), Some("LINE"), "got {m:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Command-alias editor — an in-canvas modal (Plan B) for adding, remapping and
|
||||
//! removing command-line aliases (the `ocad.pgp` table). Opened by ALIASEDIT.
|
||||
//! Rows are `(alias, command)`; edits are buffered in `alias_editor_rows` and
|
||||
//! committed to the alias table when the dialog closes. Mirrors the editable-row
|
||||
//! pattern of the attribute editor and plugin manager.
|
||||
|
||||
use crate::app::Message;
|
||||
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
|
||||
use iced::{Background, Border, Color, Element, Fill, Length, Theme};
|
||||
|
||||
/// Which column of an alias row a text edit targets.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum AliasField {
|
||||
Alias,
|
||||
Command,
|
||||
}
|
||||
|
||||
/// Right-hand lane reserved for the scrollbar so it never overlaps the ✕ column.
|
||||
const GUTTER: f32 = 16.0;
|
||||
|
||||
const BG: Color = Color { r: 0.15, g: 0.15, b: 0.15, a: 1.0 };
|
||||
const FIELD_BG: Color = Color { r: 0.12, g: 0.12, b: 0.12, a: 1.0 };
|
||||
const BORDER: Color = Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0 };
|
||||
const DIM: Color = Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 };
|
||||
const WHITE: Color = Color { r: 0.85, g: 0.85, b: 0.85, a: 1.0 };
|
||||
const ADD_C: Color = Color { r: 0.20, g: 0.40, b: 0.62, a: 1.0 };
|
||||
|
||||
fn field_style(_: &Theme, _s: text_input::Status) -> text_input::Style {
|
||||
text_input::Style {
|
||||
background: Background::Color(FIELD_BG),
|
||||
border: Border { color: BORDER, width: 1.0, radius: 3.0.into() },
|
||||
icon: WHITE,
|
||||
placeholder: DIM,
|
||||
value: WHITE,
|
||||
selection: Color { r: 0.25, g: 0.40, b: 0.60, a: 1.0 },
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the alias editor content. `rows` is the live working buffer.
|
||||
pub fn view_window(rows: &[(String, String)]) -> Element<'_, Message> {
|
||||
let title = text("Command Aliases").size(15).color(WHITE);
|
||||
let hint = text(
|
||||
"Type an alias and the command it runs (e.g. L → LINE). \
|
||||
Apply to save to ocad.pgp; closing discards unapplied edits.",
|
||||
)
|
||||
.size(11)
|
||||
.color(DIM);
|
||||
|
||||
// Right gutter reserved so the scrollbar has its own lane and never sits on
|
||||
// top of the row delete (✕) buttons. Applied to both the header and the
|
||||
// scrollable rows so the columns stay aligned.
|
||||
let gutter = iced::Padding { top: 0.0, right: GUTTER, bottom: 0.0, left: 0.0 };
|
||||
|
||||
let head = container(
|
||||
row![
|
||||
container(text("Alias").size(11).color(DIM)).width(Length::Fixed(120.0)),
|
||||
container(text("Command").size(11).color(DIM)).width(Fill),
|
||||
Space::new().width(Length::Fixed(30.0)),
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.padding(gutter);
|
||||
|
||||
let mut list = column![].spacing(3);
|
||||
for (idx, (alias, cmd)) in rows.iter().enumerate() {
|
||||
let alias_box = text_input("alias", alias)
|
||||
.on_input(move |v| Message::AliasEditorInput { idx, field: AliasField::Alias, value: v })
|
||||
.style(field_style)
|
||||
.size(13)
|
||||
.padding([3, 6])
|
||||
.width(Length::Fixed(120.0));
|
||||
let cmd_box = text_input("command", cmd)
|
||||
.on_input(move |v| Message::AliasEditorInput { idx, field: AliasField::Command, value: v })
|
||||
.style(field_style)
|
||||
.size(13)
|
||||
.padding([3, 6])
|
||||
.width(Fill);
|
||||
let del = button(crate::ui::icons::tinted(crate::ui::icons::CLOSE, 12.0, WHITE))
|
||||
.on_press(Message::AliasEditorRemove(idx))
|
||||
.padding([2, 6])
|
||||
.style(|_: &Theme, status| {
|
||||
let bg = if matches!(status, button::Status::Hovered) {
|
||||
Color { r: 0.45, g: 0.22, b: 0.22, a: 1.0 }
|
||||
} else {
|
||||
Color { r: 0.22, g: 0.22, b: 0.22, a: 1.0 }
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
border: Border { color: BORDER, width: 1.0, radius: 3.0.into() },
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
list = list.push(
|
||||
row![alias_box, cmd_box, del]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
);
|
||||
}
|
||||
|
||||
let add = button(text("+ Add alias").size(12).color(WHITE))
|
||||
.on_press(Message::AliasEditorAdd)
|
||||
.padding([4, 10])
|
||||
.style(|_: &Theme, status| {
|
||||
let bg = if matches!(status, button::Status::Hovered) {
|
||||
Color { r: 0.30, g: 0.30, b: 0.30, a: 1.0 }
|
||||
} else {
|
||||
Color { r: 0.22, g: 0.22, b: 0.22, a: 1.0 }
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
text_color: WHITE,
|
||||
border: Border { color: BORDER, width: 1.0, radius: 3.0.into() },
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
|
||||
// Apply — primary action; commits the rows to ocad.pgp and stays open.
|
||||
let apply = button(text("Apply").size(12).color(WHITE))
|
||||
.on_press(Message::AliasEditorApply)
|
||||
.padding([4, 16])
|
||||
.style(|_: &Theme, status| {
|
||||
let bg = if matches!(status, button::Status::Hovered | button::Status::Pressed) {
|
||||
Color { r: 0.24, g: 0.46, b: 0.74, a: 1.0 }
|
||||
} else {
|
||||
ADD_C
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
text_color: WHITE,
|
||||
border: Border { radius: 4.0.into(), ..Default::default() },
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
|
||||
container(
|
||||
column![
|
||||
title,
|
||||
hint,
|
||||
Space::new().height(6),
|
||||
head,
|
||||
scrollable(container(list).padding(gutter)).height(Fill),
|
||||
Space::new().height(6),
|
||||
row![add, Space::new().width(Fill), apply].align_y(iced::Center),
|
||||
]
|
||||
.spacing(6)
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
)
|
||||
.padding(12)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(BG)),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
@@ -7,3 +7,4 @@ pub mod layers;
|
||||
pub mod update_notice;
|
||||
pub mod open_progress;
|
||||
pub mod attribute_editor;
|
||||
pub mod alias_editor;
|
||||
Reference in new issue
Block a user