diff --git a/src/app/mod.rs b/src/app/mod.rs index 91034b4e9..173ac41a8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -960,6 +960,20 @@ pub(super) struct OpenCADStudio { shortcut_bindings: rustc_hash::FxHashMap, /// Working rows shown by the shortcut editor until Apply is pressed. shortcut_editor_rows: Vec<(String, String)>, + /// Row whose Key cell is armed for capture: the next key combination + /// pressed anywhere fills that row's key. While set, the keyboard + /// subscription swallows every key so captured shortcuts do not run in + /// the drawing. + shortcut_capture_row: Option, + /// True while a freshly added (top) row is an unfinished draft: it exists + /// only until its key and command are filled (success) or it is cancelled + /// (Esc / ✕ / dialog close), so the list never keeps an empty row. + shortcut_pending_add: bool, + /// True while the "Reset to default" confirmation is showing. + shortcut_reset_confirm: bool, + /// True while the "unsaved changes will be discarded" confirmation + /// overlays the editor: the user tried to close with un-applied rows. + shortcut_close_confirm: bool, // ── Command Aliases ─────────────────────────────────────────────────── /// Command-line aliases: uppercase abbreviation → uppercase command @@ -2552,6 +2566,28 @@ pub enum Message { ShortcutEditorAdd, ShortcutEditorRemove(usize), ShortcutEditorApply, + /// Apply the working rows and close the dialog. + ShortcutEditorApplyExit, + /// The check button on a draft row: finish the addition without applying. + ShortcutEditorDraftAccept, + /// Show the "Reset to default" confirmation. + ShortcutEditorResetAsk, + /// Reset bindings and rows to the shipped defaults. + ShortcutEditorResetConfirm, + /// Hide the reset confirmation without resetting. + ShortcutEditorResetDeny, + /// Discard un-applied rows and close the editor. + ShortcutEditorCloseDiscard, + /// Keep editing: hide the discard confirmation. + ShortcutEditorCloseKeep, + /// The user clicked a row's Key cell: arm capture for that row. + ShortcutCaptureStart(usize), + /// A key combination was pressed while a row's Key cell was armed. + ShortcutCaptureKey(String), + /// Disarm capture without filling the row. + ShortcutCaptureClear, + /// Cancel capture (Esc): also discards an unfinished draft row. + ShortcutCaptureCancel, /// Canonical key emitted by the global keyboard subscription. ShortcutPressed(String), // ── Command Alias Editor (ALIASEDIT) ──────────────────────────────── @@ -3414,6 +3450,10 @@ impl OpenCADStudio { // Keyboard shortcuts shortcut_bindings: rustc_hash::FxHashMap::default(), shortcut_editor_rows: Vec::new(), + shortcut_capture_row: None, + shortcut_pending_add: false, + shortcut_reset_confirm: false, + shortcut_close_confirm: false, // Command aliases (populated from ocad.pgp just after construction) command_aliases: rustc_hash::FxHashMap::default(), alias_editor_rows: Vec::new(), diff --git a/src/app/shortcuts.rs b/src/app/shortcuts.rs index 6c888fa40..f3116f38c 100644 --- a/src/app/shortcuts.rs +++ b/src/app/shortcuts.rs @@ -141,7 +141,113 @@ fn is_named_key(key: &str) -> bool { }) } +/// Input actions `run_shortcut` resolves directly instead of routing through +/// the command dispatcher — valid shortcut commands that never appear in the +/// command registry. Used to validate the shortcut editor's command column. +pub(super) const INPUT_ACTIONS: &[&str] = &[ + "FINALIZE", + "COMMANDSPACE", + "CANCEL", + "DELETESELECTED", + "BACKSPACE", + "DYNTAB", + "HISTORYPREV", + "HISTORYNEXT", + "CARETLEFT", + "CARETRIGHT", + "COMMANDHISTORY", + "TOGGLEOSNAP", + "OTRACK", + "DYNINPUT", + "SELECTALL", + "PASTECLIP", +]; + impl OpenCADStudio { + /// Commit the working rows to the live bindings, discarding an + /// unfinished draft and reporting duplicates. Shared by Apply and + /// Apply-and-Exit. + pub(super) fn finish_shortcut_editor(&mut self) { + // An add must end in a complete shortcut or a cancellation; + // applying with a half-filled draft discards the draft. + if self.shortcut_pending_add { + self.shortcut_editor_rows.remove(0); + self.shortcut_pending_add = false; + self.command_line + .push_error(crate::tf!("Incomplete shortcut row discarded.").as_ref()); + } + self.shortcut_capture_row = None; + // Rows normalizing to the same key silently overwrite each other in + // the binding map — surface the conflict instead. + let mut seen = rustc_hash::FxHashSet::default(); + let mut duplicates = Vec::new(); + for (key, _) in &self.shortcut_editor_rows { + let key = normalize_key(key); + if key.is_empty() { + continue; + } + if !seen.insert(key.clone()) { + duplicates.push(key); + } + } + if !duplicates.is_empty() { + self.command_line.push_error( + crate::tf!( + "Duplicate shortcut(s) ignored on Apply: {}", + duplicates.join(", ") + ) + .as_ref(), + ); + } + self.apply_shortcut_editor_rows(); + self.command_line.push_info( + crate::tf!("{} shortcut(s) applied.", self.shortcut_bindings.len()).as_ref(), + ); + } + + /// Reset the working rows and live bindings to the shipped defaults. + pub(super) fn reset_shortcuts_to_defaults(&mut self) { + let mut rows: Vec<(String, String)> = default_bindings() + .into_iter() + .collect(); + rows.sort_by(|a, b| a.0.cmp(&b.0)); + self.shortcut_editor_rows = rows; + self.shortcut_pending_add = false; + self.shortcut_capture_row = None; + self.shortcut_reset_confirm = false; + self.apply_shortcut_editor_rows(); + } + + /// True when the working rows differ from the live bindings — the editor + /// has un-applied changes a close would discard. + pub(super) fn shortcut_editor_dirty(&self) -> bool { + let rows: FxHashMap = self + .shortcut_editor_rows + .iter() + .filter_map(|(key, action)| { + let key = normalize_key(key); + let action = action.trim().to_uppercase(); + (!key.is_empty() && !action.is_empty()).then_some((key, action)) + }) + .collect(); + rows != self.shortcut_bindings + } + + /// A pending add finishes successfully once its draft row (row 0) has + /// both a key and a command; it then becomes a regular row of the table. + /// Called after each edit while a draft is pending. + pub(super) fn finish_pending_add(&mut self) { + if !self.shortcut_pending_add { + return; + } + if let Some((key, command)) = self.shortcut_editor_rows.first() { + if !key.trim().is_empty() && !command.trim().is_empty() { + self.shortcut_pending_add = false; + self.shortcut_capture_row = None; + } + } + } + pub(super) fn apply_shortcut_editor_rows(&mut self) { let bindings: FxHashMap = self .shortcut_editor_rows diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 020fca1c4..9ca1cdb1c 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -199,7 +199,13 @@ impl OpenCADStudio { // 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(Shortcuts) => self.shortcut_editor_rows.clear(), + Some(Shortcuts) => { + self.shortcut_editor_rows.clear(); + self.shortcut_capture_row = None; + self.shortcut_pending_add = false; + self.shortcut_reset_confirm = false; + self.shortcut_close_confirm = false; + } Some(LayerStateEditor) => { self.layer_state_edit_draft = None; self.layer_state_edit_filter.clear(); @@ -5564,13 +5570,31 @@ impl OpenCADStudio { .collect(); rows.sort_by(|a, b| a.0.cmp(&b.0)); self.shortcut_editor_rows = rows; + self.shortcut_capture_row = None; + self.shortcut_pending_add = false; + self.shortcut_reset_confirm = false; + self.shortcut_close_confirm = false; self.active_modal = Some(super::ModalKind::Shortcuts); Task::none() } Message::ShortcutsPanelClose => { + if !self.shortcut_close_confirm && self.shortcut_editor_dirty() { + self.shortcut_close_confirm = true; + return Task::none(); + } + self.shortcut_close_confirm = false; self.close_active_modal(); Task::none() } + Message::ShortcutEditorCloseDiscard => { + self.shortcut_close_confirm = false; + self.close_active_modal(); + Task::none() + } + Message::ShortcutEditorCloseKeep => { + self.shortcut_close_confirm = false; + Task::none() + } Message::ShortcutEditorInput { idx, field, value } => { use crate::ui::window::shortcuts::ShortcutField; if let Some(row) = self.shortcut_editor_rows.get_mut(idx) { @@ -5579,21 +5603,96 @@ impl OpenCADStudio { ShortcutField::Command => row.1 = value.to_uppercase(), } } + // Typing never finishes the addition — only the draft row's + // check button does, so no half-visible "ghost" row appears. Task::none() } Message::ShortcutEditorAdd => { - self.shortcut_editor_rows - .push((String::new(), String::new())); + if self.shortcut_pending_add { + // One draft at a time: re-arm its Key cell instead. + self.shortcut_capture_row = Some(0); + } else { + // The draft row goes to the top of the list so it is + // visible without scrolling, with its Key cell armed. + self.shortcut_editor_rows + .insert(0, (String::new(), String::new())); + self.shortcut_pending_add = true; + self.shortcut_capture_row = Some(0); + } + Task::none() + } + Message::ShortcutCaptureStart(idx) => { + // Clicking the armed cell again cancels capture. + self.shortcut_capture_row = match self.shortcut_capture_row { + Some(armed) if armed == idx => None, + _ => Some(idx), + }; + Task::none() + } + Message::ShortcutCaptureKey(raw) => { + if let Some(idx) = self.shortcut_capture_row.take() { + let key = crate::app::shortcuts::normalize_key(&raw); + if !key.is_empty() && self.shortcut_editor_rows.get_mut(idx).is_some() { + self.shortcut_editor_rows[idx].0 = key; + } + } + Task::none() + } + Message::ShortcutCaptureClear => { + self.shortcut_capture_row = None; + Task::none() + } + Message::ShortcutCaptureCancel => { + // Esc backs out of capture and abandons an unfinished draft. + self.shortcut_capture_row = None; + if self.shortcut_pending_add { + self.shortcut_editor_rows.remove(0); + self.shortcut_pending_add = false; + } Task::none() } Message::ShortcutEditorRemove(idx) => { if idx < self.shortcut_editor_rows.len() { self.shortcut_editor_rows.remove(idx); } + if idx == 0 { + // Removing the draft row is the ✕ cancel path. + self.shortcut_pending_add = false; + } + // Armed indices shift when a row above them disappears. + self.shortcut_capture_row = match self.shortcut_capture_row { + Some(armed) if armed == idx => None, + Some(armed) if armed > idx => Some(armed - 1), + other => other, + }; Task::none() } Message::ShortcutEditorApply => { - self.apply_shortcut_editor_rows(); + self.finish_shortcut_editor(); + Task::none() + } + Message::ShortcutEditorApplyExit => { + self.finish_shortcut_editor(); + self.close_active_modal(); + Task::none() + } + Message::ShortcutEditorDraftAccept => { + // 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_add(); + Task::none() + } + Message::ShortcutEditorResetAsk => { + self.shortcut_reset_confirm = true; + Task::none() + } + Message::ShortcutEditorResetDeny => { + self.shortcut_reset_confirm = false; + Task::none() + } + Message::ShortcutEditorResetConfirm => { + self.reset_shortcuts_to_defaults(); self.command_line.push_info( crate::tf!("{} shortcut(s) applied.", self.shortcut_bindings.len()).as_ref(), ); @@ -5793,6 +5892,17 @@ impl OpenCADStudio { if self.active_modal == Some(super::ModalKind::RecoveryPrompt) { return self.update(Message::RecoveryDecline); } + // Closing the shortcut editor with un-applied rows needs an + // explicit discard; a second close attempt (or the overlay's + // Discard button) goes through. + if self.active_modal == Some(super::ModalKind::Shortcuts) + && !self.shortcut_close_confirm + && self.shortcut_editor_dirty() + { + self.shortcut_close_confirm = true; + return Task::none(); + } + self.shortcut_close_confirm = false; let resume_open_queue = self.active_modal == Some(super::ModalKind::Recovery); self.close_active_modal(); if resume_open_queue { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 86f997cbe..014b90a84 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -118,6 +118,36 @@ fn hatch_pattern_key_event( } } +fn shortcut_capture_key_event( + event: iced::Event, + _status: iced::event::Status, + _window: window::Id, +) -> Option { + let iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event else { + return None; + }; + // Ignore bare modifier presses so holding Ctrl+Shift before the final + // key doesn't end the capture early. + if let keyboard::Key::Named(named) = &key { + if matches!( + named, + keyboard::key::Named::Control + | keyboard::key::Named::Shift + | keyboard::key::Named::Alt + | keyboard::key::Named::Super + | keyboard::key::Named::Meta + ) { + return None; + } + } + // Esc backs out of capture (and abandons an unfinished draft row) — + // binding the ESCAPE key itself is still possible via SHORTCUTS SET. + if matches!(key, keyboard::Key::Named(keyboard::key::Named::Escape)) { + return Some(Message::ShortcutCaptureCancel); + } + shortcut_key_name(&key, modifiers).map(Message::ShortcutCaptureKey) +} + fn shortcut_key_name(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> Option { let key = match key { keyboard::Key::Character(value) if !value.is_empty() => value.to_uppercase(), @@ -2198,19 +2228,12 @@ impl OpenCADStudio { } else { Subscription::none() }; - iced::Subscription::batch([ - frames, - history_tick, - grip_dwell, - hover_dwell, - nav_settle, - thumbnail_capture, - caret_blink, - web_fonts, - autosave, - plugin_drain, - single_instance, - hatch_pattern_keys, + // While a shortcut-editor Key cell is armed for capture, a dedicated + // listener records the pressed combination and swallows every other + // key so captured shortcuts do not run in the drawing. + let keyboard_events = if self.shortcut_capture_row.is_some() { + event::listen_with(shortcut_capture_key_event) + } else { event::listen_with(|ev, status, win_id| { use iced::event::Status; match ev { @@ -2340,7 +2363,22 @@ impl OpenCADStudio { } _ => None, } - }), + }) + }; + iced::Subscription::batch([ + frames, + history_tick, + grip_dwell, + hover_dwell, + nav_settle, + thumbnail_capture, + caret_blink, + web_fonts, + autosave, + plugin_drain, + single_instance, + hatch_pattern_keys, + keyboard_events, ]) } diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 0fa5e27a7..444b5fe4a 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -78,6 +78,68 @@ impl OpenCADStudio { automatic_flow(ex, crate::ui::window::about::view_window) } super::super::ModalKind::Shortcuts => { + // Keys claimed by two rows — the cells turn red and a + // persistent warning names the command already using each + // key; the last row wins in the binding map on Apply. + let mut key_rows: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for row in &self.shortcut_editor_rows { + let key = crate::app::shortcuts::normalize_key(&row.0); + if !key.is_empty() { + key_rows.entry(key).or_default().push(row); + } + } + let duplicate_keys: Vec = key_rows + .iter() + .filter(|(_, rows)| rows.len() > 1) + .map(|(key, _)| key.clone()) + .collect(); + let duplicate_conflicts: Vec<(String, String)> = key_rows + .iter() + .filter(|(_, rows)| rows.len() > 1) + .map(|(key, rows)| { + // Name the established binding: the first row with + // this key that actually has a command. + let command = rows + .iter() + .find(|(_, command)| !command.is_empty()) + .map(|(_, command)| command.clone()) + .unwrap_or_default(); + (key.clone(), command) + }) + .collect(); + let duplicate_set: rustc_hash::FxHashSet = + duplicate_keys.into_iter().collect(); + // Commands the dispatcher can't run: not a registered + // command, plugin command, alias, 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(self.command_aliases.keys().cloned()) + .chain( + crate::app::shortcuts::INPUT_ACTIONS + .iter() + .map(|action| action.to_string()), + ) + .collect(); + let unknown_commands: Vec = self + .shortcut_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, 720, @@ -85,6 +147,13 @@ impl OpenCADStudio { |flow| { crate::ui::window::shortcuts::view_window( &self.shortcut_editor_rows, + self.shortcut_capture_row, + self.shortcut_pending_add, + self.shortcut_reset_confirm, + &duplicate_set, + &duplicate_conflicts, + &unknown_set, + self.shortcut_close_confirm, flow, ) }, diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 5bb37dd15..6f38ec873 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -531,6 +531,17 @@ pub fn themed_success<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, .into() } +/// Render an icon with the foreground chosen for a success-coloured surface. +pub fn themed_success_text<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { + svg(themed_handle(bytes)) + .width(size) + .height(size) + .style(|theme: &Theme, _| svg::Style { + color: Some(theme.palette().success.base.text), + }) + .into() +} + /// Render a warning-state chrome icon from the active Iced theme. pub fn themed_warning<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { svg(themed_handle(bytes)) diff --git a/src/ui/window/shortcuts.rs b/src/ui/window/shortcuts.rs index 13e2735de..63440fa33 100644 --- a/src/ui/window/shortcuts.rs +++ b/src/ui/window/shortcuts.rs @@ -19,13 +19,54 @@ fn muted_style(theme: &Theme) -> iced::widget::text::Style { } } -pub fn view_window( - rows: &[(String, String)], +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, + } +} + +pub fn view_window<'a>( + rows: &'a [(String, String)], + capture_row: Option, + pending_add: bool, + reset_confirm: bool, + duplicate_keys: &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!("Keyboard Shortcuts")).size(15); let hint = text(t!( - "Type SHORTCUTS SET to add custom shortcuts." + "Click + Add, press a key combination, type the command, then Apply. Esc cancels the pending row." )) .size(11) .style(muted_style); @@ -49,15 +90,42 @@ pub fn view_window( let mut list = column![].spacing(3); for (idx, (key, command)) in rows.iter().enumerate() { - let key_box = text_input("Ctrl+Key", key) - .on_input(move |value| Message::ShortcutEditorInput { - idx, - field: ShortcutField::Key, - value, - }) - .size(13) - .padding([3, 6]) - .width(Length::Fixed(180.0)); + // Key cell: click to arm capture, then press the combination. The + // armed cell shows what is expected and highlights until a key + // arrives; clicking it again cancels. A key that already exists in + // another row is shown red. Manual key entry is available via + // SHORTCUTS SET. + let armed = capture_row == Some(idx); + let duplicate = !key.is_empty() && duplicate_keys.contains(key.as_str()); + let key_label = if armed { + t!("Press a key combination...").into_owned() + } else if key.is_empty() { + t!("Click, then press keys...").into_owned() + } else { + key.clone() + }; + let key_box = if armed { + button(text(key_label).size(13)) + .on_press(Message::ShortcutCaptureClear) + .padding([3, 6]) + .width(Length::Fixed(180.0)) + .style(button::primary) + } else if duplicate { + // The whole field turns red, not just the text. + button(text(key_label).size(13)) + .on_press(Message::ShortcutCaptureStart(idx)) + .padding([3, 6]) + .width(Length::Fixed(180.0)) + .style(button::danger) + } else { + button(text(key_label).size(13).style(muted_style)) + .on_press(Message::ShortcutCaptureStart(idx)) + .padding([3, 6]) + .width(Length::Fixed(180.0)) + .style(button::secondary) + }; + let unknown_command = + !command.is_empty() && unknown_commands.contains(command.trim()); let command_box = text_input(t!("command").as_ref(), command) .on_input(move |value| Message::ShortcutEditorInput { idx, @@ -67,13 +135,50 @@ pub fn view_window( .size(13) .padding([3, 6]) .width(sizing.width); - let remove = button(crate::ui::icons::themed_danger_text( - crate::ui::icons::CLOSE, - 12.0, - )) - .on_press(Message::ShortcutEditorRemove(idx)) - .padding([2, 6]) - .style(button::danger); + // An unrunnable command turns the whole field red, mirroring the + // duplicate key cell. + let command_box = if unknown_command { + command_box.style(danger_input_style) + } else { + command_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 { + // Done is only clickable for a valid row: key and command filled, + // key not already used, command actually runnable. + let done_ok = !key.is_empty() + && !command.is_empty() + && !duplicate + && !unknown_commands.contains(command.trim()); + row![ + button(crate::ui::icons::themed_success_text( + crate::ui::icons::CHECK, + 12.0, + )) + .on_press_maybe(done_ok.then_some(Message::ShortcutEditorDraftAccept)) + .padding([2, 6]) + .style(button::success), + button(crate::ui::icons::themed_danger_text( + crate::ui::icons::CLOSE, + 12.0, + )) + .on_press(Message::ShortcutCaptureCancel) + .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::ShortcutEditorRemove(idx)) + .padding([2, 6]) + .style(button::danger)] + .align_y(iced::Center) + }; list = list.push( row![key_box, command_box, remove] .spacing(8) @@ -81,16 +186,100 @@ pub fn view_window( ); } - let add = button(text(format!("+ {}", t!("Add"))).size(12)) - .on_press(Message::ShortcutEditorAdd) - .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::ShortcutCaptureCancel) + .padding([4, 10]) + .style(button::danger) + } else { + button(text(format!("+ {}", t!("Add"))).size(12)) + .on_press(Message::ShortcutEditorAdd) + .padding([4, 10]) + .style(button::secondary) + }; + // The reset confirmation replaces the shortcut 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 shortcuts"), 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 shortcuts!" + )) + .size(12) + .style(danger_text_style), + button(text(t!("Yes, reset")).size(12)) + .on_press(Message::ShortcutEditorResetConfirm) + .padding([4, 10]) + .style(button::danger), + button(text(t!("No")).size(12)) + .on_press(Message::ShortcutEditorResetDeny) + .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::ShortcutEditorResetAsk) + .padding([4, 10]) + .style(button::secondary)] + .spacing(8) + .align_y(iced::Center), + row![add], + ) + }; let apply = button(text(t!("Apply")).size(12)) .on_press(Message::ShortcutEditorApply) .padding([4, 16]) .style(button::primary); + let apply_exit = button(text(t!("Apply && Exit")).size(12)) + .on_press(Message::ShortcutEditorApplyExit) + .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 duplicate warning: shown until every conflicting key is + // resolved (re-captured, changed, or a row removed). + let mut conflict_banner = column![].spacing(2); + for (key, command) in duplicate_conflicts { + conflict_banner = conflict_banner.push( + text(crate::tf!( + "Shortcut already used for command: {} → {}", + key, + 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, @@ -98,7 +287,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) @@ -112,6 +305,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::ShortcutEditorCloseKeep) + .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::ShortcutEditorCloseDiscard) + .padding([4, 12]) + .style(button::danger), + button(text(t!("Keep editing")).size(12)) + .on_press(Message::ShortcutEditorCloseKeep) + .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() }