From afff1a40dd04048c0a6d21dd340d28b47aa8e9bc Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 1 Sep 2026 12:17:39 +0100 Subject: [PATCH] feat(ui): add press-to-capture shortcut editing to Keyboard Shortcuts dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace manual key entry with per-row capture cells: clicking + Add inserts a draft row at the top with its key cell armed, and the next key combination is recorded and normalized (Ctrl+Shift+S → CTRL+SHIFT+S). While armed, a dedicated keyboard listener swallows all input so recorded shortcuts do not execute in the drawing. Draft rows are transient and confirmed only through an explicit accept button, so the table never retains empty rows. Duplicate keys and unrunnable commands are flagged in danger styling with a persistent warning naming the conflicting command. Added reset-to-defaults with confirmation, apply-and-exit, a shortcut count, and an unsaved-changes guard on close. --- src/app/mod.rs | 40 +++++ src/app/shortcuts.rs | 106 +++++++++++++ src/app/update/mod.rs | 118 +++++++++++++- src/app/view/mod.rs | 66 ++++++-- src/app/view/modal.rs | 69 +++++++++ src/ui/icons.rs | 11 ++ src/ui/window/shortcuts.rs | 309 +++++++++++++++++++++++++++++++++---- 7 files changed, 673 insertions(+), 46 deletions(-) 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() }