feat(ui): bring ALIASEDIT to shortcut-editor parity

Port press-to-capture dialog safeguards to the alias editor: transient top draft row with explicit accept, duplicate-alias and unknown-command danger styling plus persistent banners, reset-to-defaults with inline confirmation, Apply and Apply-and-Exit, alias count, Esc-cancels-draft-first, and unsaved-changes guard on close.
This commit is contained in:
Karim Jerbi committed 2026-09-16 09:32:06 +01:00
1 parent 457f4b3dbb
commit 1057790456
5 files changed
+513 -23

No files matched your search

+86 -1
View File
@@ -164,6 +164,13 @@ fn default_map() -> FxHashMap<String, String> {
parse_pgp(DEFAULT_ALIASES_PGP)
}
/// Sorted factory-default rows for the ALIASEDIT reset action.
pub(super) fn default_alias_rows() -> Vec<(String, String)> {
let mut rows: Vec<(String, String)> = default_map().into_iter().collect();
rows.sort_by(|a, b| a.0.cmp(&b.0));
rows
}
/// Load the alias table at boot. Native reads `ocad.pgp`; web reads the same
/// text from `localStorage`. Missing or unavailable storage falls back to the
/// embedded defaults.
@@ -315,10 +322,88 @@ impl OpenCADStudio {
.alias_editor_rows
.iter()
.filter(|(a, c)| !a.trim().is_empty() && !c.trim().is_empty())
.map(|(a, c)| (a.trim().to_string(), c.trim().to_string()))
.map(|(a, c)| {
(
a.trim().to_uppercase(),
c.trim().to_uppercase(),
)
})
.collect();
self.set_command_aliases(map);
}
/// Commit the working rows, discarding an unfinished draft and reporting
/// duplicates. Shared by Apply and Apply-and-Exit. Mirrors
/// `finish_shortcut_editor`.
pub(super) fn finish_alias_editor(&mut self) {
if self.alias_pending_add && !self.alias_editor_rows.is_empty() {
self.alias_editor_rows.remove(0);
self.alias_pending_add = false;
self.command_line
.push_error(crate::tf!("Incomplete alias row discarded.").as_ref());
}
let mut seen = rustc_hash::FxHashSet::default();
let mut duplicates = Vec::new();
for (alias, _) in &self.alias_editor_rows {
let alias = alias.trim().to_uppercase();
if alias.is_empty() {
continue;
}
if !seen.insert(alias.clone()) {
duplicates.push(alias);
}
}
if !duplicates.is_empty() {
self.command_line.push_error(
crate::tf!(
"Duplicate alias(es) ignored on Apply: {}",
duplicates.join(", ")
)
.as_ref(),
);
}
self.apply_alias_editor_rows();
self.command_line.push_info(
crate::tf!("{} alias(es) applied.", self.command_aliases.len()).as_ref(),
);
}
/// Reset the working rows and live table to the shipped defaults.
pub(super) fn reset_aliases_to_defaults(&mut self) {
self.alias_editor_rows = default_alias_rows();
self.alias_pending_add = false;
self.alias_reset_confirm = false;
self.apply_alias_editor_rows();
}
/// True when the working rows differ from the live table — the editor
/// has un-applied changes a close would discard.
pub(super) fn alias_editor_dirty(&self) -> bool {
let rows: FxHashMap<String, String> = self
.alias_editor_rows
.iter()
.filter_map(|(alias, command)| {
let alias = alias.trim().to_uppercase();
let command = command.trim().to_uppercase();
(!alias.is_empty() && !command.is_empty()).then_some((alias, command))
})
.collect();
rows != self.command_aliases
}
/// A pending add finishes once its draft row (row 0) has both an alias
/// and a command; it then becomes a regular row. Called by the draft
/// row's check button — typing alone never finishes it.
pub(super) fn finish_pending_alias_add(&mut self) {
if !self.alias_pending_add {
return;
}
if let Some((alias, command)) = self.alias_editor_rows.first() {
if !alias.trim().is_empty() && !command.trim().is_empty() {
self.alias_pending_add = false;
}
}
}
}
#[cfg(test)]
+29
View File
@@ -1099,6 +1099,16 @@ pub(super) struct OpenCADStudio {
/// Working buffer for the ALIASEDIT modal: `(alias, command)` rows being
/// edited. Seeded from `command_aliases` on open, committed back on close.
alias_editor_rows: Vec<(String, String)>,
/// True while a freshly added (top) row is an unfinished draft: it exists
/// only until its alias and command are filled (accept) or it is cancelled
/// (Esc / ✕ / dialog close), so the list never keeps an empty row.
/// Mirrors `shortcut_pending_add`.
alias_pending_add: bool,
/// True while the "Reset to default" confirmation is showing.
alias_reset_confirm: bool,
/// True while the "unsaved changes will be discarded" confirmation
/// overlays the editor: the user tried to close with un-applied rows.
alias_close_confirm: bool,
// ── Named Parameters ──────────────────────────────────────────────────
/// Working buffer for the PARAMETERS modal. Unlike `alias_editor_rows`,
@@ -2986,6 +2996,22 @@ pub enum Message {
AliasEditorRemove(usize),
/// Commit the edited rows to the alias table (Apply button); stays open.
AliasEditorApply,
/// Apply the working rows and close the dialog.
AliasEditorApplyExit,
/// The check button on a draft row: finish the addition without applying.
AliasEditorDraftAccept,
/// Cancel a pending draft row (Esc / Cancel add).
AliasEditorDraftCancel,
/// Show the "Reset to default" confirmation.
AliasEditorResetAsk,
/// Reset aliases to the shipped defaults.
AliasEditorResetConfirm,
/// Hide the reset confirmation without resetting.
AliasEditorResetDeny,
/// Discard un-applied rows and close the editor.
AliasEditorCloseDiscard,
/// Keep editing: hide the discard confirmation.
AliasEditorCloseKeep,
// ── Named Parameters (PARAMETERS) ───────────────────────────────────
/// Open the named-parameter editor, seeding rows from the active tab's
/// `Scene::named_parameters`.
@@ -3980,6 +4006,9 @@ impl OpenCADStudio {
// Command aliases (populated from ocad.pgp just after construction)
command_aliases: rustc_hash::FxHashMap::default(),
alias_editor_rows: Vec::new(),
alias_pending_add: false,
alias_reset_confirm: false,
alias_close_confirm: false,
named_parameter_editor_rows: Vec::new(),
// Layout Manager
layout_manager_selected: "Model".to_string(),
+80 -3
View File
@@ -201,7 +201,12 @@ impl OpenCADStudio {
Some(GeometricTolerance) => self.geometric_tolerance = None,
// Closing (✕) discards edits made since the last Apply — matching the
// style editors. Committing happens only through the Apply button.
Some(Aliases) => self.alias_editor_rows.clear(),
Some(Aliases) => {
self.alias_editor_rows.clear();
self.alias_pending_add = false;
self.alias_reset_confirm = false;
self.alias_close_confirm = false;
}
Some(Shortcuts) => {
self.shortcut_editor_rows.clear();
self.shortcut_capture_row = None;
@@ -283,6 +288,12 @@ impl OpenCADStudio {
if matches!(msg, Message::CommandEscape)
|| matches!(&msg, Message::ShortcutPressed(key) if key.rsplit('+').next() == Some("ESCAPE"))
{
// Esc backs out of a pending ALIASEDIT draft first, mirroring
// the shortcut editor's capture cancel; the next Esc closes.
if self.active_modal == Some(super::ModalKind::Aliases) && self.alias_pending_add
{
return self.update(Message::AliasEditorDraftCancel);
}
return self.update(Message::CloseModal);
}
if is_modal_blocked_key_msg(&msg) {
@@ -6577,6 +6588,9 @@ impl OpenCADStudio {
.collect();
rows.sort_by(|a, b| a.0.cmp(&b.0));
self.alias_editor_rows = rows;
self.alias_pending_add = false;
self.alias_reset_confirm = false;
self.alias_close_confirm = false;
self.active_modal = Some(super::ModalKind::Aliases);
Task::none()
}
@@ -6591,25 +6605,80 @@ impl OpenCADStudio {
AliasField::Command => rowdata.1 = value,
}
}
// Typing never finishes the addition — only the draft row's
// check button does, so no half-visible "ghost" row appears.
Task::none()
}
Message::AliasEditorAdd => {
self.alias_editor_rows.push((String::new(), String::new()));
if self.alias_pending_add {
// One draft at a time: keep the existing top draft.
} else {
// The draft row goes to the top of the list so it is
// visible without scrolling.
self.alias_editor_rows
.insert(0, (String::new(), String::new()));
self.alias_pending_add = true;
}
Task::none()
}
Message::AliasEditorDraftAccept => {
// The check button: finish the addition (keep the row in the
// working table) without applying it. Only complete drafts
// can be accepted — the button is disabled otherwise.
self.finish_pending_alias_add();
Task::none()
}
Message::AliasEditorDraftCancel => {
// Esc backs out and abandons an unfinished draft.
if self.alias_pending_add && !self.alias_editor_rows.is_empty() {
self.alias_editor_rows.remove(0);
self.alias_pending_add = false;
}
Task::none()
}
Message::AliasEditorRemove(idx) => {
if idx < self.alias_editor_rows.len() {
self.alias_editor_rows.remove(idx);
}
if idx == 0 {
// Removing the draft row is the ✕ cancel path.
self.alias_pending_add = false;
}
Task::none()
}
Message::AliasEditorApply => {
self.apply_alias_editor_rows();
self.finish_alias_editor();
Task::none()
}
Message::AliasEditorApplyExit => {
self.finish_alias_editor();
self.close_active_modal();
Task::none()
}
Message::AliasEditorResetAsk => {
self.alias_reset_confirm = true;
Task::none()
}
Message::AliasEditorResetDeny => {
self.alias_reset_confirm = false;
Task::none()
}
Message::AliasEditorResetConfirm => {
self.reset_aliases_to_defaults();
self.command_line.push_info(
crate::tf!("{} alias(es) applied.", self.command_aliases.len()).as_ref(),
);
Task::none()
}
Message::AliasEditorCloseDiscard => {
self.alias_close_confirm = false;
self.close_active_modal();
Task::none()
}
Message::AliasEditorCloseKeep => {
self.alias_close_confirm = false;
Task::none()
}
// ── Named Parameters (PARAMETERS) ─────────────────────────────────
Message::NamedParametersOpen => {
@@ -7260,6 +7329,14 @@ impl OpenCADStudio {
return Task::none();
}
self.shortcut_close_confirm = false;
if self.active_modal == Some(super::ModalKind::Aliases)
&& !self.alias_close_confirm
&& self.alias_editor_dirty()
{
self.alias_close_confirm = true;
return Task::none();
}
self.alias_close_confirm = false;
if self.active_modal == Some(super::ModalKind::DraftingSettings)
&& !self.drafting_settings_close_confirm
&& self.drafting_settings_dirty()
+65
View File
@@ -167,6 +167,65 @@ impl OpenCADStudio {
)
}
super::super::ModalKind::Aliases => {
// Aliases claimed by two rows — the cells turn red and a
// persistent warning names the command already using each
// alias; the last row wins in the alias map on Apply.
let mut alias_rows: std::collections::BTreeMap<String, Vec<&(String, String)>> =
std::collections::BTreeMap::new();
for row in &self.alias_editor_rows {
let alias = row.0.trim().to_uppercase();
if !alias.is_empty() {
alias_rows.entry(alias).or_default().push(row);
}
}
let duplicate_aliases: Vec<String> = alias_rows
.iter()
.filter(|(_, rows)| rows.len() > 1)
.map(|(alias, _)| alias.clone())
.collect();
let duplicate_conflicts: Vec<(String, String)> = alias_rows
.iter()
.filter(|(_, rows)| rows.len() > 1)
.map(|(alias, rows)| {
let command = rows
.iter()
.find(|(_, command)| !command.is_empty())
.map(|(_, command)| command.clone())
.unwrap_or_default();
(alias.clone(), command)
})
.collect();
let duplicate_set: rustc_hash::FxHashSet<String> =
duplicate_aliases.into_iter().collect();
// Commands the dispatcher can't run: not a registered
// command, plugin command, or input action.
let valid: rustc_hash::FxHashSet<String> =
crate::command::all_registered_command_names()
.into_iter()
.map(str::to_uppercase)
.chain(
self.command_line
.dynamic_commands
.iter()
.map(|cmd| cmd.to_uppercase()),
)
.chain(
crate::app::shortcuts::INPUT_ACTIONS
.iter()
.map(|action| action.to_string()),
)
.collect();
let unknown_commands: Vec<String> = self
.alias_editor_rows
.iter()
.filter_map(|(_, command)| {
let command = command.trim();
(!command.is_empty() && !valid.contains(command))
.then(|| command.to_string())
})
.collect();
let unknown_set: rustc_hash::FxHashSet<String> =
unknown_commands.into_iter().collect();
sized_flow(
ex,
480,
@@ -174,6 +233,12 @@ impl OpenCADStudio {
|flow| {
crate::ui::window::alias_editor::view_window(
&self.alias_editor_rows,
self.alias_pending_add,
self.alias_reset_confirm,
&duplicate_set,
&duplicate_conflicts,
&unknown_set,
self.alias_close_confirm,
flow,
)
},