diff --git a/src/app/alias.rs b/src/app/alias.rs index d9c3aeb00..dc0a3a169 100644 --- a/src/app/alias.rs +++ b/src/app/alias.rs @@ -164,6 +164,13 @@ fn default_map() -> FxHashMap { 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 = 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)] diff --git a/src/app/mod.rs b/src/app/mod.rs index 2b10dfee4..7581f9956 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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(), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 9b4882193..185fbddac 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -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() diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 498ba1dcd..993c7ec9d 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -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> = + 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 = 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 = + 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 = + 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 = 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 = + 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, ) }, diff --git a/src/ui/window/alias_editor.rs b/src/ui/window/alias_editor.rs index b3da9f8aa..3e0bdb277 100644 --- a/src/ui/window/alias_editor.rs +++ b/src/ui/window/alias_editor.rs @@ -1,8 +1,10 @@ //! 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. +//! committed to the alias table on Apply. Mirrors the shortcut editor: +//! transient draft row with explicit accept, duplicate/unknown validation, +//! reset-to-defaults with confirmation, apply-and-exit, alias count, and an +//! unsaved-changes guard on close. use crate::app::Message; use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; @@ -25,14 +27,54 @@ fn muted_style(theme: &Theme) -> iced::widget::text::Style { } } +fn danger_text_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.palette().danger.base.color), + } +} + +/// Text-input variant flagging an invalid value: danger border and value +/// color on the theme's danger weak background. +fn danger_input_style( + theme: &Theme, + status: iced::widget::text_input::Status, +) -> iced::widget::text_input::Style { + let danger = theme.palette().danger.base; + iced::widget::text_input::Style { + background: Background::Color(theme.palette().danger.weak.color), + border: iced::border::rounded(4) + .color(danger.color) + .width( + if matches!( + status, + iced::widget::text_input::Status::Focused { is_hovered: _ } + ) { + 1.5 + } else { + 1.0 + }, + ), + icon: danger.color, + placeholder: danger.color.scale_alpha(0.7), + value: danger.text, + selection: danger.color, + } +} + /// Build the alias editor content. `rows` is the live working buffer. -pub fn view_window( - rows: &[(String, String)], +pub fn view_window<'a>( + rows: &'a [(String, String)], + pending_add: bool, + reset_confirm: bool, + duplicate_aliases: &rustc_hash::FxHashSet, + duplicate_conflicts: &[(String, String)], + unknown_commands: &rustc_hash::FxHashSet, + close_confirm: bool, sizing: crate::ui::modal::ModalSizing, -) -> Element<'_, Message> { +) -> Element<'a, Message> { let title = text(t!("Command Aliases")).size(15); let hint = text(t!( - "Type an alias and the command it runs (e.g. L → LINE). Apply to save to ocad.pgp; closing discards unapplied edits." + "Click + Add alias, type the alias and command, accept the row, then Apply. Esc cancels the pending row." )) .size(11) .style(muted_style); @@ -46,7 +88,7 @@ pub fn view_window( row![ container(text(t!("Alias")).size(11).style(muted_style)).width(Length::Fixed(120.0)), container(text(t!("Command")).size(11).style(muted_style)).width(sizing.width), - Space::new().width(Length::Fixed(30.0)), + Space::new().width(Length::Fixed(62.0)), ] .spacing(8), ) @@ -54,39 +96,167 @@ pub fn view_window( let mut list = column![].spacing(3); for (idx, (alias, cmd)) in rows.iter().enumerate() { + let alias_key = alias.trim().to_uppercase(); + let duplicate = !alias_key.is_empty() && duplicate_aliases.contains(alias_key.as_str()); let alias_box = text_input(t!("alias").as_ref(), alias) .on_input(move |v| Message::AliasEditorInput { idx, field: AliasField::Alias, value: v }) .size(13) .padding([3, 6]) .width(Length::Fixed(120.0)); + let alias_box = if duplicate { + alias_box.style(danger_input_style) + } else { + alias_box + }; + let unknown_command = + !cmd.trim().is_empty() && unknown_commands.contains(cmd.trim()); let cmd_box = text_input(t!("command").as_ref(), cmd) .on_input(move |v| Message::AliasEditorInput { idx, field: AliasField::Command, value: v }) .size(13) .padding([3, 6]) .width(sizing.width); - let del = button(crate::ui::icons::themed_danger_text(crate::ui::icons::CLOSE, 12.0)) + let cmd_box = if unknown_command { + cmd_box.style(danger_input_style) + } else { + cmd_box + }; + // Draft rows get a check (finish the addition, without applying) and + // a cancel ✕; committed rows get the trash bin. + let remove = if pending_add && idx == 0 { + let done_ok = !alias.trim().is_empty() + && !cmd.trim().is_empty() + && !duplicate + && !unknown_commands.contains(cmd.trim()); + row![ + button(crate::ui::icons::themed_success_text( + crate::ui::icons::CHECK, + 12.0, + )) + .on_press_maybe(done_ok.then_some(Message::AliasEditorDraftAccept)) + .padding([2, 6]) + .style(button::success), + button(crate::ui::icons::themed_danger_text( + crate::ui::icons::CLOSE, + 12.0, + )) + .on_press(Message::AliasEditorDraftCancel) + .padding([2, 6]) + .style(button::danger), + ] + .spacing(4) + .align_y(iced::Center) + } else { + row![button(crate::ui::icons::themed_danger_text( + crate::ui::icons::TRASH, + 12.0, + )) .on_press(Message::AliasEditorRemove(idx)) .padding([2, 6]) - .style(button::danger); + .style(button::danger)] + .align_y(iced::Center) + }; list = list.push( - row![alias_box, cmd_box, del] + row![alias_box, cmd_box, remove] .spacing(8) .align_y(iced::Center), ); } - let add = button(text(t!("+ Add alias")).size(12)) - .on_press(Message::AliasEditorAdd) - .padding([4, 10]) - .style(button::secondary); - + // While a draft row is pending, the add button doubles as the visible + // cancel affordance (Esc works too). + let add = if pending_add { + button(text(t!("Cancel add (Esc)")).size(12)) + .on_press(Message::AliasEditorDraftCancel) + .padding([4, 10]) + .style(button::danger) + } else { + button(text(t!("+ Add alias")).size(12)) + .on_press(Message::AliasEditorAdd) + .padding([4, 10]) + .style(button::secondary) + }; + // The reset confirmation replaces the alias count so the warning + // gets the whole left side of the bar. + let stats = if reset_confirm { + row![Space::new().width(Length::Fixed(0.0))] + } else { + row![text(format!("{}: {}", t!("Number of aliases"), rows.len())) + .size(12) + .style(muted_style)] + }; + // Reset asks for confirmation in place: the add/reset buttons are + // replaced by the question with Yes / No. + let (reset_area, add_area) = if reset_confirm { + ( + row![ + text(t!( + "Are you sure you want to reset? You will lose all your current aliases!" + )) + .size(12) + .style(danger_text_style), + button(text(t!("Yes, reset")).size(12)) + .on_press(Message::AliasEditorResetConfirm) + .padding([4, 10]) + .style(button::danger), + button(text(t!("No")).size(12)) + .on_press(Message::AliasEditorResetDeny) + .padding([4, 10]) + .style(button::secondary), + ] + .spacing(8) + .align_y(iced::Center), + row![Space::new().width(Length::Fixed(0.0))], + ) + } else { + ( + row![button(text(t!("Reset to default")).size(12)) + .on_press(Message::AliasEditorResetAsk) + .padding([4, 10]) + .style(button::secondary)] + .spacing(8) + .align_y(iced::Center), + row![add], + ) + }; // Apply — primary action; commits the rows to ocad.pgp and stays open. let apply = button(text(t!("Apply")).size(12)) .on_press(Message::AliasEditorApply) .padding([4, 16]) .style(button::primary); + let apply_exit = button(text(t!("Apply && Exit")).size(12)) + .on_press(Message::AliasEditorApplyExit) + .padding([4, 16]) + .style(button::primary); + // The reset confirmation takes over the whole action bar. + let apply_area = if reset_confirm { + row![Space::new().width(Length::Fixed(0.0))] + } else { + row![apply, apply_exit].spacing(8).align_y(iced::Center) + }; - container( + // Persistent validation warnings: shown until every conflicting alias is + // resolved (edited or a row removed) and every command is runnable. + let mut conflict_banner = column![].spacing(2); + for (alias, command) in duplicate_conflicts { + conflict_banner = conflict_banner.push( + text(crate::tf!( + "Alias already used for command: {} → {}", + alias, + command + )) + .size(12) + .style(danger_text_style), + ); + } + for command in unknown_commands { + conflict_banner = conflict_banner.push( + text(crate::tf!("Unknown command: {}", command)) + .size(12) + .style(danger_text_style), + ); + } + + let content = container( column![ title, hint, @@ -94,7 +264,11 @@ pub fn view_window( head, scrollable(container(list).padding(gutter)).height(sizing.height), Space::new().height(6), - row![add, Space::new().width(sizing.width), apply].align_y(iced::Center), + conflict_banner, + Space::new().height(4), + row![stats, add_area, reset_area, Space::new().width(sizing.width), apply_area] + .spacing(8) + .align_y(iced::Center), ] .spacing(6) .width(sizing.width) @@ -108,6 +282,66 @@ pub fn view_window( theme.palette().background.base.color, )), ..Default::default() - }) - .into() + }); + + // Unsaved-changes guard: closing with un-applied rows stacks a dimmed + // shield and a confirmation panel on top of the editor. + if !close_confirm { + return content.into(); + } + let shield = iced::widget::mouse_area( + container(Space::new()) + .width(Length::Fill) + .height(Length::Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme + .palette() + .background + .strongest + .color + .scale_alpha(0.55), + )), + ..Default::default() + }), + ) + .on_press(Message::AliasEditorCloseKeep) + .interaction(iced::mouse::Interaction::Idle); + let panel = container( + column![ + text(t!("Unsaved changes will be discarded.")).size(14), + Space::new().height(10), + row![ + button(text(t!("Discard && close")).size(12)) + .on_press(Message::AliasEditorCloseDiscard) + .padding([4, 12]) + .style(button::danger), + button(text(t!("Keep editing")).size(12)) + .on_press(Message::AliasEditorCloseKeep) + .padding([4, 12]) + .style(button::secondary), + ] + .spacing(8), + ] + .spacing(4), + ) + .padding(16) + .width(Length::Fixed(320.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.base.color, + )), + border: iced::Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + }); + let centered = container(panel) + .width(Length::Fill) + .height(Length::Fill) + .align_x(iced::alignment::Horizontal::Center) + .align_y(iced::alignment::Vertical::Center); + iced::widget::stack![content, iced::widget::opaque(shield), centered].into() }