From e0aa597f1e644370b67bc3e8aab326e4c4cc9d8f Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Tue, 18 Aug 2026 23:09:53 +0800 Subject: [PATCH] feat(app): custom shortcuts, Keyboard preferences tab, Action Search - Shortcut override layer over the action registry: /shortcuts file (idkeystroke, gpui syntax), loaded before bind_keys at startup, saved as diff-only (all-default removes the file), conflict resolution steals the key from its previous owner; rebind_keys applies changes live (clear_key_bindings + bind_keys + menu rebuild). - Preferences gains a Keyboard tab: menu-hierarchy action list, name/path/shortcut filter, click-to-capture key editor (any key assigns, Backspace unbinds, Esc cancels), Reset Selected/All, Import/Export. - Action Search on '/': modal listing 'Menu > Submenu > Action', live filter, arrows + Enter dispatch through the same path as menu clicks. Keystroke interception handles capture/search input ahead of the global keymap; modal opening is deferred to avoid a nested update_window failure. --- src/actions.rs | 470 ++++++++++++++++++++- src/app.rs | 426 ++++++++++++++++++- src/dialogs.rs | 1091 +++++++++++++++++++++++++++++++++++++++++++++++- src/i18n.rs | 56 +++ 4 files changed, 2022 insertions(+), 21 deletions(-) diff --git a/src/actions.rs b/src/actions.rs index 02c6993cf..8b0dc5b6c 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -37,6 +37,9 @@ //! reports the item id, the keymap dispatches the gpui action, and both end //! up in `OakApp::dispatch_action_id`. +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + use gpui::{Action, KeyBinding}; // One macro call generates all three views of the registry, so they can @@ -320,22 +323,23 @@ pub fn entry_for_menu_id(id: usize) -> Option<&'static ActionEntry> { REGISTRY.iter().find(|entry| entry.action.menu_id() == id) } -/// The global key bindings for every registry default key (context `None`: -/// the shell dispatches them wherever the focus is, and the modal guard in -/// the shell's action listeners suppresses them while a dialog is open). +/// The global key bindings for every action's *effective* key (context +/// `None`: the shell dispatches them wherever the focus is, and the modal +/// guard in the shell's action listeners suppresses them while a dialog is +/// open). An unbound action contributes nothing. pub fn key_bindings() -> Vec { let mut bindings = Vec::new(); for entry in REGISTRY { - for key in entry.default_keys { + for key in effective_keys(entry) { let binding = KeyBinding::load( - key, + &key, (entry.build)(), None, false, None, &gpui::DummyKeyboardMapper, ) - .unwrap_or_else(|_| panic!("invalid default key {key:?} for {}", entry.cpp_id)); + .unwrap_or_else(|_| panic!("invalid effective key {key:?} for {}", entry.cpp_id)); bindings.push(binding); } } @@ -347,8 +351,10 @@ pub fn key_bindings() -> Vec { /// other platforms the same keys render as `Ctrl+Shift+Z`). The space key /// name is localized through i18n (`shortcut.space`). pub fn display_shortcut(action: ActionId) -> Option { - let key = action.entry().default_keys.first()?; - let keystroke = gpui::Keystroke::parse(key).expect("registry keys parse (tests enforce it)"); + let keys = effective_keys(action.entry()); + let key = keys.first()?; + let keystroke = + gpui::Keystroke::parse(key).expect("effective keys parse (tests enforce it)"); // `secondary-` parses to `platform` on macOS and `control` elsewhere; // the modifier renderers below follow the same split. @@ -397,6 +403,290 @@ pub fn display_shortcut(action: ActionId) -> Option { Some(label) } +// --------------------------------------------------------------------------- +// Custom shortcut overrides (the `/shortcuts` file) +// --------------------------------------------------------------------------- +// +// The C++ `MainWindow` keeps a `/shortcuts` text file of +// `idkeys` lines (mainwindow.cpp:768-863): each line overrides one +// action's key(s), the file is read at startup and written back only with the +// entries that differ from the defaults (an empty diff removes the file). +// This port keeps the same line shape but stores the key sequence in **gpui +// keystroke syntax** (`secondary-s`, `alt-shift-z`, `delete`, …) instead of +// Qt's `QKeySequence::toString` (`Ctrl+S`): +// +// * `id\tkey` — bind that single key; +// * `id\t` (empty value) — unbind the action entirely; +// * absent line — the registry defaults. +// +// That is a deliberate deviation from the C++ file (a C++ `Ctrl+S` line +// would not parse as a gpui keystroke and vice versa): the C++ *format* is +// kept, not the byte-level values. The key strings themselves are +// platform-independent — `cmd-`, `super-` and `win-` all parse to gpui's +// "platform" modifier on every OS, so a file written on macOS loads on +// Linux (and the canonical form used for comparisons normalizes the +// `secondary-` spelling used by the registry defaults). +// +// The override table is a process-global behind a mutex (like the config +// store): the menu bar, `key_bindings` and `display_shortcut` all read the +// effective keys on demand, so a change is visible the moment it is made and +// the shell's `rebind_keys` + `rebuild_menu_bar` turn it live. + +/// One action's override: +/// * absent from the map — the registry defaults apply; +/// * [`ShortcutOverride::Unbound`] — explicitly unbound (no keys at all); +/// * [`ShortcutOverride::Keys`] — the custom key(s). +enum ShortcutOverride { + Unbound, + Keys(Vec), +} + +/// The process-global override table, keyed by the stable cpp id. +static OVERRIDES: OnceLock>> = OnceLock::new(); + +/// The global override table. +fn overrides() -> &'static Mutex> { + OVERRIDES.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Serializes tests that touch the process-global override table (the same +/// pattern the i18n tests use for the language global). +#[cfg(test)] +pub(crate) fn shortcuts_test_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +/// The path of the custom-shortcuts file: `/shortcuts`, exactly like +/// the C++ `MainWindow::get_custom_shortcuts_file`. +pub fn custom_shortcuts_path() -> String { + let dir = oakcommon::filefunctions::FileFunctions::new() + .get_configuration_location() + .unwrap_or_else(|_| std::env::temp_dir().to_string_lossy().into_owned()); + format!("{}/shortcuts", dir.trim_end_matches('/')) +} + +/// Parses one `id\tkey` line. `None` for blank lines, missing ids and keys +/// that are not valid gpui keystrokes. An empty key value means "unbound". +/// +/// The tab is split *before* trimming (a bare trailing `\t` would otherwise +/// be trimmed away and the unbound marker lost); only the id and the key +/// value are trimmed. +fn parse_shortcut_line(line: &str) -> Option<(String, Vec)> { + // `str::lines` strips `\n` but keeps the `\r` of CRLF files. + let line = line.strip_suffix('\r').unwrap_or(line); + if line.trim().is_empty() { + return None; + } + let (id, value) = line.split_once('\t')?; // a line without a tab is malformed + let id = id.trim(); + if REGISTRY.iter().all(|entry| entry.cpp_id != id) { + return None; + } + let value = value.trim(); + let keys = if value.is_empty() { + Vec::new() + } else { + if gpui::Keystroke::parse(value).is_err() { + return None; + } + vec![value.to_string()] + }; + Some((id.to_string(), keys)) +} + +/// Parses a whole shortcuts file body (`id\tkey` lines; blank lines and +/// malformed lines are skipped). +pub fn parse_shortcut_file(contents: &str) -> Vec<(String, Vec)> { + contents.lines().filter_map(parse_shortcut_line).collect() +} + +/// Serializes override lines back to `id\tkey` (empty key = unbound). +pub fn serialize_shortcut_file(entries: &[(String, Vec)]) -> String { + let mut out = String::new(); + for (index, (id, keys)) in entries.iter().enumerate() { + if index > 0 { + out.push('\n'); + } + out.push_str(id); + out.push('\t'); + if let Some(key) = keys.first() { + out.push_str(key); + } + } + out +} + +/// Replaces the whole override table with `entries` (startup load and the +/// Keyboard tab's Import button — anything not listed falls back to default). +pub fn apply_shortcut_overrides(entries: Vec<(String, Vec)>) { + let mut map = HashMap::new(); + for (id, keys) in entries { + map.insert( + id, + if keys.is_empty() { + ShortcutOverride::Unbound + } else { + ShortcutOverride::Keys(keys) + }, + ); + } + *overrides().lock().unwrap() = map; +} + +/// Removes every override (the Keyboard tab's Reset All). +pub fn reset_all_custom_shortcuts() { + overrides().lock().unwrap().clear(); +} + +/// Removes the override of one action (back to the registry defaults). +pub fn reset_custom_shortcut(cpp_id: &str) { + overrides().lock().unwrap().remove(cpp_id); +} + +/// Sets the custom keys of one action; an empty list unbinds it. +pub fn set_custom_shortcut(cpp_id: &str, keys: Vec) { + overrides().lock().unwrap().insert( + cpp_id.to_string(), + if keys.is_empty() { + ShortcutOverride::Unbound + } else { + ShortcutOverride::Keys(keys) + }, + ); +} + +/// Whether any override is currently set (tests / file presence checks). +pub fn has_custom_shortcuts() -> bool { + !overrides().lock().unwrap().is_empty() +} + +/// The canonical (parse → unparse) form of a keystroke string, used to +/// compare keys regardless of the `secondary-` vs `cmd-`/`super-`/`win-` +/// spelling (the parser maps all of them to the same modifier bits). +fn canonical_key(key: &str) -> Option { + gpui::Keystroke::parse(key).ok().map(|keystroke| keystroke.unparse()) +} + +/// The canonical form of a key list (accepts both `&str` and `String` +/// slices, so the `&'static [&str]` registry defaults and the `Vec` +/// overrides share one code path). +fn canonical_keys>(keys: &[K]) -> Vec { + keys.iter().filter_map(|key| canonical_key(key.as_ref())).collect() +} + +/// The action's effective key list: the override when one is set, else the +/// registry defaults (empty = unbound). +pub fn effective_keys(entry: &ActionEntry) -> Vec { + match overrides().lock().unwrap().get(entry.cpp_id) { + Some(ShortcutOverride::Unbound) => Vec::new(), + Some(ShortcutOverride::Keys(keys)) => keys.clone(), + None => entry.default_keys.iter().map(|key| key.to_string()).collect(), + } +} + +/// The registry defaults in canonical form (the save-diff baseline). +fn default_canonical(entry: &ActionEntry) -> Vec { + canonical_keys(entry.default_keys) +} + +/// Loads `/shortcuts` and applies it (startup). A missing file is a +/// no-op (everything stays at the defaults). +pub fn load_custom_shortcuts() { + let path = custom_shortcuts_path(); + if let Ok(contents) = std::fs::read_to_string(&path) { + apply_shortcut_overrides(parse_shortcut_file(&contents)); + } +} + +/// Loads overrides from an explicit file path (the Keyboard tab's Import). +/// Returns the number of lines applied. +pub fn load_custom_shortcuts_from(path: &str) -> Result { + let contents = std::fs::read_to_string(path).map_err(|e| e.to_string())?; + let entries = parse_shortcut_file(&contents); + let count = entries.len(); + apply_shortcut_overrides(entries); + Ok(count) +} + +/// Writes the overrides that differ from the registry defaults to `path` +/// (canonical comparison, so an override spelling `cmd-s` for a default +/// `secondary-s` counts as unchanged). An all-default state removes the +/// file, exactly like the C++. Returns the number of lines written. +pub fn save_custom_shortcuts_to(path: &str) -> Result { + let mut lines: Vec<(String, Vec)> = Vec::new(); + { + let map = overrides().lock().unwrap(); + for entry in REGISTRY { + let Some(override_) = map.get(entry.cpp_id) else { + continue; + }; + let effective = match override_ { + ShortcutOverride::Unbound => Vec::new(), + ShortcutOverride::Keys(keys) => keys.clone(), + }; + if canonical_keys(&effective) != default_canonical(entry) { + lines.push((entry.cpp_id.to_string(), effective)); + } + } + } + if lines.is_empty() { + match std::fs::remove_file(path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.to_string()), + } + return Ok(0); + } + std::fs::write(path, serialize_shortcut_file(&lines)).map_err(|e| e.to_string())?; + Ok(lines.len()) +} + +/// Saves the diff to the configured location (every key-capture commit +/// calls this so the change survives restarts). +pub fn save_custom_shortcuts() -> Result { + save_custom_shortcuts_to(&custom_shortcuts_path()) +} + +/// The first registry action whose *effective* keys contain the canonical +/// `canon`, if any (the current owner of a key, for conflict detection). +pub fn owner_of_shortcut(canon: &str) -> Option { + REGISTRY + .iter() + .find(|entry| { + effective_keys(entry) + .iter() + .any(|key| canonical_key(key).as_deref() == Some(canon)) + }) + .map(|entry| entry.action) +} + +/// Moves the canonical key `canon` away from every *other* action that +/// currently binds it, shrinking their override by one key (or unbinding +/// them). Returns the action the key was first taken from, if any — the +/// conflict policy: the new assignment wins and the displaced action falls +/// back to its remaining keys / to none. Simple and explicit (the alternative +/// — refusing the assignment — leaves the user stuck when the default keys +/// collide with a popular choice). +pub fn steal_shortcut_for(entry: &ActionEntry, canon: &str) -> Option { + let mut stolen = None; + for other in REGISTRY { + if other.action == entry.action { + continue; + } + let has = effective_keys(other) + .iter() + .any(|key| canonical_key(key).as_deref() == Some(canon)); + if has { + let mut keys = effective_keys(other); + keys.retain(|key| canonical_key(key).as_deref() != Some(canon)); + set_custom_shortcut(other.cpp_id, keys); + stolen.get_or_insert(other.action); + } + } + stolen +} + /// The timeline's editing tools (the C++ `Tool` enum's pointer tools): the /// Tools menu's mutually exclusive group. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -597,6 +887,7 @@ mod tests { #[test] #[cfg(target_os = "macos")] fn display_shortcut_formats_labels() { + let _guard = shortcuts_test_lock().lock().unwrap(); assert_eq!( display_shortcut(ActionId::Redo).as_deref(), Some("⇧⌘Z") @@ -619,4 +910,167 @@ mod tests { ); assert!(display_shortcut(ActionId::About).is_none()); } + + // ------------------------------------------------------------------- + // Custom shortcut overrides (stage 7) + // ------------------------------------------------------------------- + + /// A unique temporary directory for one test (the `shortcuts` file + /// round-trip), so parallel tests never collide on the same path. + fn temp_dir(label: &str) -> String { + static COUNTER: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "oak-shortcuts-test-{label}-{}-{n}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).unwrap(); + path.to_string_lossy().into_owned() + } + + /// Parsing skips blank lines, unknown ids and unparseable keystrokes; an + /// empty value after the tab means "unbound". + #[test] + fn parse_shortcut_file_skips_malformed_lines() { + let entries = parse_shortcut_file( + "newproj\tsecondary-n\n\nbogusaction\tsecondary-x\nundo\t\nsaveproj\tnot-a-key\ncut\tsecondary-c\n", + ); + assert_eq!( + entries, + vec![ + ("newproj".to_string(), vec!["secondary-n".to_string()]), + ("undo".to_string(), vec![]), // unbound + ("cut".to_string(), vec!["secondary-c".to_string()]), + ] + ); + } + + /// Write → clear → reload round-trips the effective keys, and the file + /// only carries the entries that differ from the registry defaults. + #[test] + fn shortcuts_file_round_trips_through_the_override_table() { + let _guard = shortcuts_test_lock().lock().unwrap(); + reset_all_custom_shortcuts(); + let dir = temp_dir("roundtrip"); + let path = format!("{dir}/shortcuts"); + + // `newproj` = its own default (spelled in registry syntax) → not a + // diff; the other two differ (`undo` gets unbound). + set_custom_shortcut("newproj", vec!["secondary-n".to_string()]); + set_custom_shortcut("saveproj", vec!["cmd-alt-s".to_string()]); + set_custom_shortcut("undo", Vec::new()); // unbound + assert_eq!(save_custom_shortcuts_to(&path).unwrap(), 2); + + let contents = std::fs::read_to_string(&path).unwrap(); + assert!(contents.contains("saveproj\t"), "file: {contents}"); + assert!(contents.contains("undo\t"), "file: {contents}"); + assert!(!contents.contains("newproj"), "file: {contents}"); + + // Reload into a fresh table. + reset_all_custom_shortcuts(); + assert!(!has_custom_shortcuts()); + assert_eq!(load_custom_shortcuts_from(&path).unwrap(), 2); + assert_eq!( + effective_keys(ActionId::SaveProject.entry()), + vec!["cmd-alt-s".to_string()] + ); + assert_eq!( + effective_keys(ActionId::Undo.entry()), + Vec::::new() + ); + // The un-overridden action keeps its registry default. + assert_eq!( + effective_keys(ActionId::NewProject.entry()), + vec!["secondary-n".to_string()] + ); + } + + /// A default-equal override is dropped on save (canonical comparison), and + /// an all-default table removes the file entirely (the C++ behavior). + #[test] + fn save_writes_only_entries_that_differ_from_default() { + let _guard = shortcuts_test_lock().lock().unwrap(); + let dir = temp_dir("diff"); + let path = format!("{dir}/shortcuts"); + + set_custom_shortcut("newproj", vec!["secondary-n".to_string()]); + set_custom_shortcut("undo", vec!["cmd-alt-z".to_string()]); + assert_eq!(save_custom_shortcuts_to(&path).unwrap(), 1); + let contents = std::fs::read_to_string(&path).unwrap(); + assert!(contents.contains("undo\tcmd-alt-z"), "file: {contents}"); + assert!(!contents.contains("newproj"), "file: {contents}"); + + // All-default → the file disappears. + reset_all_custom_shortcuts(); + set_custom_shortcut("newproj", vec!["secondary-n".to_string()]); + assert_eq!(save_custom_shortcuts_to(&path).unwrap(), 0); + assert!(!std::path::Path::new(&path).exists()); + } + + /// Effective keys fall back to the registry defaults and the multi-key + /// defaults stay intact until overridden. + #[test] + fn effective_keys_fall_back_to_defaults() { + let _guard = shortcuts_test_lock().lock().unwrap(); + reset_all_custom_shortcuts(); + // Delete defaults to ["delete", "backspace"]. + assert_eq!( + effective_keys(ActionId::Delete.entry()), + vec!["delete".to_string(), "backspace".to_string()] + ); + set_custom_shortcut("delete", vec!["secondary-x".to_string()]); + assert_eq!( + effective_keys(ActionId::Delete.entry()), + vec!["secondary-x".to_string()] + ); + reset_custom_shortcut("delete"); + assert_eq!( + effective_keys(ActionId::Delete.entry()), + vec!["delete".to_string(), "backspace".to_string()] + ); + } + + /// Conflict resolution: taking a key away from another action moves the + /// binding (the displaced action falls back to its remaining keys, or to + /// none). + #[test] + fn stealing_a_key_moves_the_binding_away() { + let _guard = shortcuts_test_lock().lock().unwrap(); + reset_all_custom_shortcuts(); + let canon = canonical_key("secondary-c").expect("copy's key parses"); + assert_eq!(owner_of_shortcut(&canon), Some(ActionId::Copy)); + + // The capture flow: steal the key away from its current owner, then + // assign it to the target. + let stolen = steal_shortcut_for(ActionId::Paste.entry(), &canon); + assert_eq!(stolen, Some(ActionId::Copy)); + set_custom_shortcut(ActionId::Paste.entry().cpp_id, vec![canon.clone()]); + assert_eq!(owner_of_shortcut(&canon), Some(ActionId::Paste)); + assert!( + effective_keys(ActionId::Copy.entry()).is_empty(), + "the displaced Copy must be unbound" + ); + + // A key nobody owns steals nothing. + reset_all_custom_shortcuts(); + assert_eq!(steal_shortcut_for(ActionId::Paste.entry(), "f24"), None); + } + + /// Overriding a default with the same key keeps `display_shortcut` stable + /// (the canonical comparison treats `secondary-` and `cmd-` as equal). + #[test] + #[cfg(target_os = "macos")] + fn display_shortcut_uses_the_effective_key() { + let _guard = shortcuts_test_lock().lock().unwrap(); + set_custom_shortcut("saveproj", vec!["cmd-alt-s".to_string()]); + assert_eq!( + display_shortcut(ActionId::SaveProject).as_deref(), + Some("⌥⌘S") + ); + set_custom_shortcut("saveproj", Vec::new()); // unbound → no label + assert!(display_shortcut(ActionId::SaveProject).is_none()); + reset_all_custom_shortcuts(); + } } diff --git a/src/app.rs b/src/app.rs index 2e93951e5..e353a3c95 100644 --- a/src/app.rs +++ b/src/app.rs @@ -58,7 +58,7 @@ use gpui_widgets::theme::{apply_theme, OakTheme}; use gpui_widgets::viewer::PlaybackClock; use crate::actions::{ActionId, Tool}; -use crate::dialogs::{ExportDialogContent, PreferencesContent}; +use crate::dialogs::{ExportDialogContent, PreferencesDialogContent}; use crate::oakui::{AppEngine, ExportSession, MockEngine, Monitor, RealEngine}; use crate::panels::commands as panel_commands; use crate::panels::effect_library::EffectLibraryPanel; @@ -112,6 +112,8 @@ mod modal_ids { /// The OFX plugin progress dialog (driven by the plugin-progress /// channel in the tick loop). pub const PLUGIN_PROGRESS: usize = 10; + /// The action search dialog (Help > Search Actions…, the `/` key). + pub const ACTION_SEARCH: usize = 11; } /// What a picked platform-dialog path should do. @@ -134,7 +136,7 @@ enum ModalState { None, Preferences { modal: Entity, - content: Entity, + content: Entity, }, Export { modal: Entity, @@ -162,6 +164,11 @@ enum ModalState { modal: Entity, content: Entity>, }, + /// The action search dialog (Help > Search Actions…, the `/` key). + ActionSearch { + modal: Entity, + content: Entity, + }, } /// A running export: the session the tick loop drains for progress. @@ -180,7 +187,8 @@ impl ModalState { | ModalState::Manager { modal, .. } | ModalState::ManagerRename { modal, .. } | ModalState::ManagerDelete { modal, .. } - | ModalState::Proxy { modal, .. } => Some(modal.clone()), + | ModalState::Proxy { modal, .. } + | ModalState::ActionSearch { modal, .. } => Some(modal.clone()), } } } @@ -392,7 +400,11 @@ impl OakApp { .detach(); // --- keyboard map --------------------------------------------------- - // Register every registry default key as a global binding (context + // Load the persisted custom shortcut overrides (`/shortcuts`) + // *before* binding, so a user's shortcuts file wins over the registry + // defaults. The bindings below are built from the effective keys. + crate::actions::load_custom_shortcuts(); + // Register every action's effective key as a global binding (context // `None`): the keystrokes dispatch the gpui actions, which bubble to // the shell's `on_action` listeners — the same path the menu clicks // take through `on_menu`. @@ -1038,6 +1050,8 @@ impl OakApp { | A::MulticamSwitchNoSplit7 | A::MulticamSwitchNoSplit8 | A::MulticamSwitchNoSplit9 => {} + // --- Help -------------------------------------------------------- + A::ActionSearch => self.open_action_search(cx), // --- everything else is a placeholder -------------------------- other => println!( "[action] {} not wired yet (placeholder)", @@ -1694,16 +1708,35 @@ impl OakApp { } } - /// Opens the preferences dialog. Theme/language selections emit - /// [`crate::dialogs::PreferencesEvent`]s, applied to the shell chrome - /// immediately; the typed cache directory commits when the dialog closes. + /// Opens the preferences dialog (General + Keyboard tabs). Theme/language + /// selections emit [`crate::dialogs::PreferencesEvent`]s, applied to the + /// shell chrome immediately; the typed cache directory commits when the + /// dialog closes; a shortcut change re-binds the key map and rebuilds the + /// menu bar, so the new keys are live without a restart. + /// + /// The build is deferred to the end of the current app update (like + /// [`Self::open_action_search`]): the dialog is usually opened from a + /// shortcut or a menu click, both of which dispatch inside a window + /// update where `spawn_modal`'s nested `update_window` would silently + /// fail. pub fn open_preferences(&mut self, cx: &mut Context) { + let weak = cx.weak_entity(); + cx.defer(move |app| { + if let Some(this) = weak.upgrade() { + this.update(app, |this, cx| this.open_preferences_now(cx)); + } + }); + } + + /// The deferred half of [`Self::open_preferences`]: builds the tabbed + /// dialog and subscribes to its [`crate::dialogs::PreferencesEvent`]s. + fn open_preferences_now(&mut self, cx: &mut Context) { self.spawn_modal(cx, |window, app| { - let content = app.new(|cx| PreferencesContent::new(window, cx)); + let content = app.new(|cx| PreferencesDialogContent::new(window, cx)); let modal = app.new(|cx| { Modal::new( modal_ids::PREFERENCES, - ModalOptions::new(crate::i18n::tr("preferences.title"), px(480.0)) + ModalOptions::new(crate::i18n::tr("preferences.title"), px(720.0)) .with_button(DialogButton::primary(crate::i18n::tr("dialog.close"))), window, cx, @@ -1724,6 +1757,9 @@ impl OakApp { this.rebuild_menu_bar(cx); cx.notify(); } + crate::dialogs::PreferencesEvent::ShortcutsChanged => { + this.rebind_keys(cx); + } }, ) .detach(); @@ -1738,6 +1774,83 @@ impl OakApp { } } + /// Re-applies the global key bindings and rebuilds the menu bar after a + /// shortcut override change. The gpui key map is replaced wholesale (its + /// `bind_keys` only appends) and the menu labels re-read the effective + /// keys, so the change is live immediately — no restart. + fn rebind_keys(&mut self, cx: &mut Context) { + cx.clear_key_bindings(); + cx.bind_keys(crate::actions::key_bindings()); + self.rebuild_menu_bar(cx); + cx.notify(); + } + + /// Opens the action search dialog (Help > Search Actions…, the `/` key). + /// Enter / double-click in the dialog executes the action through the same + /// [`Self::dispatch_action_id`] path the menu clicks take, so the behavior + /// can never diverge from a menu click. + /// + /// The actual modal build is deferred to the end of the current app update: + /// keyboard shortcuts and menu clicks dispatch *inside* a window update, + /// and `spawn_modal`'s nested `update_window` would fail (and silently + /// drop the dialog) there. `defer` runs after the window is back in the + /// app's map, so the modal opens one tick later. + pub fn open_action_search(&mut self, cx: &mut Context) { + if !matches!(self.modal, ModalState::None) { + return; + } + let weak = cx.weak_entity(); + cx.defer(move |app| { + if let Some(this) = weak.upgrade() { + this.update(app, |this, cx| this.open_action_search_now(cx)); + } + }); + } + + /// The deferred half of [`Self::open_action_search`]: builds the modal on + /// the main window, subscribes to its execute events and focuses the search + /// field. + fn open_action_search_now(&mut self, cx: &mut Context) { + self.spawn_modal(cx, |window, app| { + let content = app.new(|cx| crate::dialogs::ActionSearchContent::new(window, cx)); + let modal = app.new(|cx| { + Modal::new( + modal_ids::ACTION_SEARCH, + ModalOptions::new( + crate::i18n::tr("menu.help.action_search"), + px(640.0), + ), + window, + cx, + ) + .with_content(content.clone()) + }); + ModalState::ActionSearch { modal, content } + }); + if let ModalState::ActionSearch { content, .. } = &self.modal { + let content = content.clone(); + cx.subscribe( + &content, + |this, _content, event: &crate::dialogs::ActionSearchEvent, cx| { + let crate::dialogs::ActionSearchEvent::Execute(action) = event; + this.close_modal(cx); + this.dispatch_action_id(*action, cx); + }, + ) + .detach(); + // Keyboard-first: focus the search field as soon as the dialog is + // up (the shell's action dispatch already suppresses the global + // key map while the modal is open). + if let Some(handle) = cx.windows().first() { + let content = content.clone(); + let _ = cx.update_window(*handle, |_root, window, app| { + let focus = content.read(app).search_focus(app); + window.focus(&focus, app); + }); + } + } + } + /// Opens the proxy settings dialog (Tools > Proxy Settings; the C++ /// `ProxyDialog`): the global generation settings plus the footage /// proxy list with Generate / Delete buttons. @@ -2275,6 +2388,35 @@ pub(crate) fn make_menus_for_test() -> Vec { make_menus(MenuState::new(true)) } +/// The menu-bar *leaf* actions in hierarchy order, each with its localized +/// "Menu > Submenu > …" path. The Keyboard preferences tab and the action +/// search dialog both enumerate the menu bar like the C++ does +/// (`PreferencesKeyboardTab::setup_kbd_shortcuts` / +/// `ActionSearch::search_update`), so the two share one walk. Panel-context +/// hotkeys (the [`HIDDEN_MENU_ID`](crate::actions::HIDDEN_MENU_ID) multicam +/// switches) are excluded by construction — they have no menu item. +pub(crate) fn menu_action_paths() -> Vec<(ActionId, String)> { + let mut out = Vec::new(); + for entry in make_menus(MenuState::new(true)) { + let top = entry.title.to_string(); + walk_menu_for_actions(&entry.menu, &top, &mut out); + } + out +} + +/// Recurses a menu, emitting leaf items (submenu headers extend the path and +/// are not emitted themselves — their id is the first child's id). +fn walk_menu_for_actions(menu: &Menu, path: &str, out: &mut Vec<(ActionId, String)>) { + for item in &menu.items { + if let Some(submenu) = &item.submenu { + let label = item.label.to_string(); + walk_menu_for_actions(submenu, &format!("{path} > {label}"), out); + } else if let Some(entry) = crate::actions::entry_for_menu_id(item.id) { + out.push((entry.action, path.to_string())); + } + } +} + /// Command-line arguments the app accepts. #[derive(Debug, Clone, Default)] struct AppArgs { @@ -2425,6 +2567,8 @@ mod tests { /// language — and the whole menu bar flips language with `i18n`. #[test] fn language_menu_tracks_the_active_language() { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap(); + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); let view_entry = |dark: bool| -> MenuBarEntry { @@ -2486,6 +2630,8 @@ mod tests { /// The theme submenu's checkmark follows the `dark` flag. #[test] fn theme_menu_checkmark_follows_dark_flag() { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap(); + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); let dark_item = |dark: bool| -> gpui_widgets::menu::MenuItem { @@ -2517,6 +2663,8 @@ mod tests { /// across both languages. #[test] fn file_and_edit_menus_cover_the_project_lifecycle() { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap(); + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); let entry = |title: &str| -> MenuBarEntry { @@ -2571,6 +2719,8 @@ mod tests { /// entity's weak handle (regression test for the Preferences crash). #[gpui::test] async fn preferences_dialog_opens_without_crashing(cx: &mut TestAppContext) { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap(); + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); crate::i18n::set_language(crate::i18n::Language::EnUs); @@ -2606,6 +2756,8 @@ mod tests { /// action. #[test] fn every_shortcut_maps_to_a_menu_item() { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap(); + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); crate::i18n::set_language(crate::i18n::Language::EnUs); @@ -2650,6 +2802,8 @@ mod tests { /// every platform. #[test] fn menu_shortcut_labels_match_the_table() { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap(); + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); crate::i18n::set_language(crate::i18n::Language::EnUs); @@ -2684,6 +2838,8 @@ mod tests { /// bubbles to the shell's key listener and dispatches 回放 → 播放/暂停). #[gpui::test] async fn space_toggles_program_playback(cx: &mut TestAppContext) { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap(); + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); let (window, root) = mock_shell(cx); @@ -2714,6 +2870,8 @@ mod tests { /// the still-unwired ripple-to-in/out (q/w). #[gpui::test] async fn keymap_defaults_dispatch_their_actions(cx: &mut TestAppContext) { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap(); + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); let (window, root) = mock_shell(cx); @@ -2825,6 +2983,8 @@ mod tests { /// at the playhead. #[gpui::test] async fn edit_shortcuts_dispatch_to_the_engine(cx: &mut TestAppContext) { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap(); + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); let (window, root) = mock_shell(cx); @@ -2879,6 +3039,8 @@ mod tests { /// dialog's text fields must never trigger editing actions). #[gpui::test] async fn shortcuts_are_suppressed_while_a_modal_is_open(cx: &mut TestAppContext) { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap(); + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); let (window, root) = mock_shell(cx); @@ -2931,6 +3093,252 @@ mod tests { ); } + // ------------------------------------------------------------------- + // Action search + custom shortcuts (stage 7) + // ------------------------------------------------------------------- + + + + /// The `/` key opens the action search dialog (the registry's ActionSearch + /// default key), and Escape dismisses it. + #[gpui::test] + async fn action_search_opens_from_the_slash_key(cx: &mut TestAppContext) { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap(); + let _lang = crate::i18n::lang_test_lock().lock().unwrap(); + let (window, root) = mock_shell(cx); + // A leftover real shortcuts file must not shadow the default `/` + // binding under test. + crate::actions::reset_all_custom_shortcuts(); + cx.update(|app| root.update(app, |app, cx| app.rebind_keys(cx))); + cx.run_until_parked(); + + cx.dispatch_keystroke(window.into(), gpui::Keystroke::parse("/").unwrap()); + cx.run_until_parked(); + cx.update_window(window.into(), |_root, window, cx| { + window.draw(cx).clear(); + }) + .expect("window is still open"); + assert!( + cx.read(|app| matches!( + root.read(app).modal, + ModalState::ActionSearch { .. } + )), + "the / key should open the action search dialog" + ); + + // Escape closes it again (the modal's own handler). + cx.dispatch_keystroke(window.into(), gpui::Keystroke::parse("escape").unwrap()); + cx.run_until_parked(); + assert!( + cx.read(|app| matches!(root.read(app).modal, ModalState::None)), + "escape closes the action search dialog" + ); + } + + /// Arrow keys move the search selection and Enter runs the selected action + /// through the same dispatch path the menu clicks take (here the dialog + /// closes because the action dispatched successfully). + #[gpui::test] + async fn action_search_arrows_and_enter_dispatch_the_selection( + cx: &mut TestAppContext, + ) { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap(); + let _lang = crate::i18n::lang_test_lock().lock().unwrap(); + let (window, root) = mock_shell(cx); + crate::actions::reset_all_custom_shortcuts(); + cx.update(|app| root.update(app, |app, cx| app.rebind_keys(cx))); + cx.run_until_parked(); + + cx.dispatch_keystroke(window.into(), gpui::Keystroke::parse("/").unwrap()); + cx.run_until_parked(); + assert!( + cx.read(|app| matches!( + root.read(app).modal, + ModalState::ActionSearch { .. } + )), + "the search dialog is open" + ); + + // Down selects the first listed action, Enter runs it. The empty query + // lists every menu-bar action, so the first one is New Project. + cx.dispatch_keystroke(window.into(), gpui::Keystroke::parse("down").unwrap()); + cx.run_until_parked(); + cx.dispatch_keystroke(window.into(), gpui::Keystroke::parse("enter").unwrap()); + cx.run_until_parked(); + + assert!( + cx.read(|app| matches!(root.read(app).modal, ModalState::None)), + "executing the selected action closes the dialog" + ); + } + + /// The preferences dialog opens from its keyboard shortcut too (⌘,), and + /// its Keyboard tab enumerates the menu-bar actions — the deferred-modal + /// fix matters here: a shortcut dispatches inside a window update where + /// `spawn_modal` would otherwise silently fail. + #[gpui::test] + async fn preferences_opens_from_its_shortcut_with_the_keyboard_tab( + cx: &mut TestAppContext, + ) { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap(); + let _lang = crate::i18n::lang_test_lock().lock().unwrap(); + let (window, root) = mock_shell(cx); + crate::actions::reset_all_custom_shortcuts(); + cx.update(|app| root.update(app, |app, cx| app.rebind_keys(cx))); + cx.run_until_parked(); + + cx.dispatch_keystroke(window.into(), gpui::Keystroke::parse("secondary-,").unwrap()); + cx.run_until_parked(); + cx.update_window(window.into(), |_root, window, cx| { + window.draw(cx).clear(); + }) + .expect("window is still open"); + assert!( + cx.read(|app| matches!(root.read(app).modal, ModalState::Preferences { .. })), + "⌘, opens the preferences dialog" + ); + + // The tabbed content carries the Keyboard tab with a non-empty action + // list (the general tab stays the default active tab). + let rows = cx.read(|app| match &root.read(app).modal { + ModalState::Preferences { content, .. } => content + .read(app) + .keyboard_tab_row_count(app), + _ => 0, + }); + assert!(rows > 0, "the keyboard tab lists the menu-bar actions"); + } + + /// End to end through the Keyboard tab: switching to it, clicking the first + /// action's capture field and pressing a key assigns the new binding (the + /// override layer + interceptor + save path in one flow). + #[gpui::test] + async fn keyboard_tab_capture_assigns_a_shortcut(cx: &mut TestAppContext) { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap(); + let _lang = crate::i18n::lang_test_lock().lock().unwrap(); + let (window, root) = mock_shell(cx); + crate::actions::reset_all_custom_shortcuts(); + cx.update(|app| root.update(app, |app, cx| app.on_menu(menu_ids::PREFERENCES, cx))); + cx.run_until_parked(); + cx.update_window(window.into(), |_root, window, cx| { + window.draw(cx).clear(); + }) + .expect("window is still open"); + cx.run_until_parked(); + + let mut cx = VisualTestContext::from_window(window.into(), cx).into_mut(); + // Switch to the Keyboard tab. + let tab = cx + .debug_bounds("prefs-tab-keyboard") + .expect("keyboard tab button rendered"); + cx.simulate_click(tab.center(), gpui::Modifiers::none()); + cx.run_until_parked(); + // Enter capture on the first row (New Project). + let field = cx + .debug_bounds("keyboard-capture-0") + .expect("first capture field rendered"); + cx.simulate_click(field.center(), gpui::Modifiers::none()); + cx.run_until_parked(); + // Press a key → it becomes the new binding. + cx.dispatch_keystroke(window.into(), gpui::Keystroke::parse("secondary-x").unwrap()); + cx.run_until_parked(); + drop(cx); + + let expected = gpui::Keystroke::parse("secondary-x").unwrap().unparse(); + assert_eq!( + crate::actions::effective_keys(ActionId::NewProject.entry()), + vec![expected], + "capture assigns the pressed key to the row's action" + ); + // The change is live: the shortcut display (used by the menus and the + // row label) follows the override. + assert_ne!( + crate::actions::display_shortcut(ActionId::NewProject), + Some("⌘N".to_string()), + "the label no longer shows the default key" + ); + assert!( + crate::actions::display_shortcut(ActionId::NewProject).is_some(), + "the assigned key still shows a label" + ); + } + + /// Escape during a capture cancels the capture but keeps the dialog open + /// (the interceptor stops the key before it can bubble to the modal's own + /// Escape handler). + #[gpui::test] + async fn keyboard_tab_capture_escape_cancels_without_closing(cx: &mut TestAppContext) { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap(); + let _lang = crate::i18n::lang_test_lock().lock().unwrap(); + let (window, root) = mock_shell(cx); + crate::actions::reset_all_custom_shortcuts(); + cx.update(|app| root.update(app, |app, cx| app.on_menu(menu_ids::PREFERENCES, cx))); + cx.run_until_parked(); + cx.update_window(window.into(), |_root, window, cx| { + window.draw(cx).clear(); + }) + .expect("window is still open"); + cx.run_until_parked(); + + let mut cx = VisualTestContext::from_window(window.into(), cx).into_mut(); + let tab = cx + .debug_bounds("prefs-tab-keyboard") + .expect("keyboard tab button rendered"); + cx.simulate_click(tab.center(), gpui::Modifiers::none()); + cx.run_until_parked(); + let field = cx + .debug_bounds("keyboard-capture-0") + .expect("first capture field rendered"); + cx.simulate_click(field.center(), gpui::Modifiers::none()); + cx.run_until_parked(); + cx.dispatch_keystroke(window.into(), gpui::Keystroke::parse("escape").unwrap()); + cx.run_until_parked(); + + let modal_still_open = + cx.read(|app| matches!(root.read(app).modal, ModalState::Preferences { .. })); + let override_keys = crate::actions::effective_keys(ActionId::NewProject.entry()); + drop(cx); + assert!(modal_still_open, "escape cancels the capture, not the dialog"); + assert_eq!( + override_keys, + vec!["secondary-n".to_string()], + "no override was written by the cancelled capture" + ); + } + + /// A shortcut override re-binds the global key map immediately: the new + /// key drives the action, the displaced default key no longer does. + #[gpui::test] + async fn custom_shortcut_overrides_are_live_after_rebind(cx: &mut TestAppContext) { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap(); + let _lang = crate::i18n::lang_test_lock().lock().unwrap(); + let (window, root) = mock_shell(cx); + crate::actions::reset_all_custom_shortcuts(); + + // Move Snapping from its default `s` to `f5` and apply the new map. + crate::actions::set_custom_shortcut("snapping", vec!["f5".to_string()]); + cx.update(|app| root.update(app, |app, cx| app.rebind_keys(cx))); + cx.run_until_parked(); + + let snap = |cx: &TestAppContext| { + cx.read(|app| root.read(app).timeline.read(app).state.snap_enabled) + }; + let before = snap(cx); + cx.dispatch_keystroke(window.into(), gpui::Keystroke::parse("f5").unwrap()); + cx.run_until_parked(); + let after = snap(cx); + assert_ne!(before, after, "f5 toggles snapping after the override"); + + // The displaced default key is inert now. + let steady = snap(cx); + cx.dispatch_keystroke(window.into(), gpui::Keystroke::parse("s").unwrap()); + cx.run_until_parked(); + assert_eq!( + snap(cx), + steady, + "the displaced default s no longer toggles snapping" + ); + } /// 文件 → 导入素材… opens the *platform* path picker (not the in-window /// file dialog) and routes the picked path to the engine's import; the diff --git a/src/dialogs.rs b/src/dialogs.rs index 22b897d3e..fcc40a0bf 100644 --- a/src/dialogs.rs +++ b/src/dialogs.rs @@ -27,14 +27,18 @@ use gpui::colors::DefaultColors; use gpui::prelude::*; -use gpui::{div, px, App, Context, Entity, Render, SharedString, Window}; -use gpui_elements::editable_text::{text_input, EditableTextState, StringStorage}; +use gpui::{ + div, px, App, Context, ElementId, Entity, EventEmitter, Focusable, FocusHandle, Keystroke, + PathPromptOptions, Render, SharedString, Window, +}; +use gpui_elements::editable_text::{text_input, EditableTextState, StringStorage, TextChanged}; use gpui_widgets::checkbox::{CheckBox, CheckBoxEvent, CheckState}; use gpui_widgets::combo_box::{ComboBox, ComboBoxEvent, ComboBoxOption}; use gpui_widgets::slider::SliderModel; use gpui_widgets::spinbox::{SpinBox, SpinBoxEvent}; -use gpui_widgets::value::{SliderValue, ValueKind}; +use gpui_widgets::value::ValueKind; +use crate::actions::ActionId; use crate::i18n; use crate::oakui::real::{ audio_input_device, audio_input_devices, audio_output_device, audio_output_devices, @@ -53,13 +57,17 @@ use crate::oakui::real::{ /// A request the preferences dialog emits for the host shell (the settings /// themselves are written into the config store directly; these need -/// shell chrome — the menu bar / theme — to re-render). +/// shell chrome — the menu bar / theme / key map — to re-render). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PreferencesEvent { /// The theme dropdown changed (the payload is the new dark flag). ThemeChanged(bool), /// The language dropdown changed (already applied to the i18n global). LanguageChanged, + /// The custom shortcut overrides changed (the Keyboard tab); the host + /// must re-bind the global key map and rebuild the menu bar so the new + /// keys take effect immediately. + ShortcutsChanged, } impl gpui::EventEmitter for PreferencesContent {} @@ -1119,3 +1127,1078 @@ impl Render for ProxyDialogContent { .child(settings_group) } } + +// --------------------------------------------------------------------------- +// Preferences: the tabbed host (General + Keyboard) +// --------------------------------------------------------------------------- + +/// The preferences dialog tabs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PreferencesTab { + /// The grouped settings (language / theme / backend / cache / …). + General, + /// The custom-shortcuts editor. + Keyboard, +} + +/// A fully transparent color (for un-selected rows / tabs). +fn transparent() -> gpui::Rgba { + gpui::Rgba { + r: 0.0, + g: 0.0, + b: 0.0, + a: 0.0, + } +} + +/// The tabbed preferences dialog content: the existing grouped settings plus +/// the Keyboard tab — the Rust counterpart of the C++ `PreferencesDialog` +/// hosting the `PreferencesKeyboardTab` (`preferenceskeyboardtab.cpp`). +/// +/// The host re-emits the general tab's [`PreferencesEvent`]s (theme / +/// language) and turns the keyboard tab's [`KeyboardEvent::Changed`] into +/// [`PreferencesEvent::ShortcutsChanged`], so the app shell only subscribes to +/// this one content entity. +pub struct PreferencesDialogContent { + active: PreferencesTab, + general: Entity, + keyboard: Entity, +} + +impl EventEmitter for PreferencesDialogContent {} + +impl PreferencesDialogContent { + /// Builds both tabs (the general tab keeps its existing behavior; the + /// keyboard tab lists the current menu-bar actions). + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let general = cx.new(|cx| PreferencesContent::new(window, cx)); + let keyboard = cx.new(|cx| KeyboardTabContent::new(window, cx)); + cx.subscribe(&general, |_this, _general, event: &PreferencesEvent, cx| match event { + PreferencesEvent::ThemeChanged(dark) => { + cx.emit(PreferencesEvent::ThemeChanged(*dark)); + } + PreferencesEvent::LanguageChanged => cx.emit(PreferencesEvent::LanguageChanged), + PreferencesEvent::ShortcutsChanged => {} + }) + .detach(); + cx.subscribe(&keyboard, |_this, _keyboard, event: &KeyboardEvent, cx| { + if matches!(event, KeyboardEvent::Changed) { + cx.emit(PreferencesEvent::ShortcutsChanged); + } + }) + .detach(); + Self { + active: PreferencesTab::General, + general, + keyboard, + } + } + + /// Commits the general tab's free-text fields (the cache directory), for + /// the host when the dialog closes. + pub fn commit_cache_dir(&self, cx: &App) { + self.general.read(cx).commit_cache_dir(cx); + } + + /// The keyboard tab's action-row count (tests). + pub fn keyboard_tab_row_count(&self, cx: &App) -> usize { + self.keyboard.read(cx).row_count() + } +} + +impl Render for PreferencesDialogContent { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let content = match self.active { + PreferencesTab::General => self.general.clone().into_any_element(), + PreferencesTab::Keyboard => self.keyboard.clone().into_any_element(), + }; + div() + .flex() + .flex_col() + .gap_3() + .w_full() + .child( + div() + .flex() + .gap_1() + .child( + tab_button(PreferencesTab::General, self.active, &colors, cx), + ) + .child(tab_button(PreferencesTab::Keyboard, self.active, &colors, cx)), + ) + .child(content) + } +} + +/// One tab switcher button of the preferences dialog. +fn tab_button( + tab: PreferencesTab, + active: PreferencesTab, + colors: &gpui::colors::Colors, + cx: &mut Context, +) -> gpui::Stateful { + let selected = tab == active; + let label = match tab { + PreferencesTab::General => i18n::tr("preferences.tab.general"), + PreferencesTab::Keyboard => i18n::tr("preferences.tab.keyboard"), + }; + div() + .id(match tab { + PreferencesTab::General => "prefs-tab-general", + PreferencesTab::Keyboard => "prefs-tab-keyboard", + }) + .debug_selector(move || match tab { + PreferencesTab::General => "prefs-tab-general".into(), + PreferencesTab::Keyboard => "prefs-tab-keyboard".into(), + }) + .px_3() + .py_1() + .rounded_md() + .cursor_pointer() + .bg(if selected { colors.selected } else { transparent() }) + .text_color(if selected { colors.selected_text } else { colors.text }) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.active = tab; + cx.notify(); + })) + .child(label) +} + +/// A small pill-shaped text button (the keyboard tab's footer buttons). The +/// caller chains the click handler on the returned element. +fn pill_button( + id: &'static str, + label: impl Into, + bg: gpui::Rgba, + fg: gpui::Rgba, +) -> gpui::Stateful { + let label: SharedString = label.into(); + div() + .id(id) + .px_3() + .py_1() + .rounded_md() + .cursor_pointer() + .bg(bg) + .text_color(fg) + .child(label) +} + +// --------------------------------------------------------------------------- +// Preferences → Keyboard tab +// --------------------------------------------------------------------------- + +/// A request the keyboard tab emits; the tabbed host re-emits it as +/// [`PreferencesEvent::ShortcutsChanged`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyboardEvent { + /// The shortcut overrides changed (a key was assigned / cleared / reset / + /// imported). The host must re-bind the key map and rebuild the menu bar. + Changed, +} + +impl EventEmitter for KeyboardTabContent {} + +/// One row of the keyboard tab: a menu-bar action with its hierarchy. +struct KeyboardRow { + action: ActionId, + /// The top-level menu title (the section header), localized. + section: String, + /// The full "Menu > Submenu > …" path, localized. + path: String, + /// The capture field's focus handle. + focus: FocusHandle, +} + +/// The Preferences → Keyboard tab: a searchable, section-grouped list of every +/// menu-bar action with a click-to-capture shortcut editor, plus Reset +/// Selected / Reset All and Import / Export — the Rust counterpart of the C++ +/// `PreferencesKeyboardTab`. +/// +/// # Capture +/// +/// Clicking a row's shortcut field enters capture mode: a process-wide +/// [`gpui::App::intercept_keystrokes`] subscription suppresses the global key +/// map for as long as the capture is active, so the field's `on_key_down` +/// sees *every* key — including the letters, digits and arrows that the +/// registry binds (the shell's action listeners would otherwise swallow them +/// before the widget-level handlers run, the same problem Qt solves with +/// `QKeySequenceEdit`'s `ShortcutOverride`). The capture field then decides: +/// +/// * any real key (plus modifiers) becomes the action's new binding; +/// * Backspace / Delete unbind the action (back to "None"); +/// * Escape cancels the capture. +/// +/// Every commit writes the override diff to `/shortcuts` and emits +/// [`KeyboardEvent::Changed`], so the change is live immediately (no restart). +/// +/// # Conflicts +/// +/// Assigning a key that another action already binds *moves* the binding: the +/// displaced action loses the key (falling back to its remaining keys, or to +/// none) and the status line says so. Simple and explicit — the alternative +/// (refusing the assignment) would leave the user stuck when two popular +/// defaults collide. +pub struct KeyboardTabContent { + query: Entity, + rows: Vec, + filter: String, + capturing: Option, + selected: Option, + confirm_reset_all: bool, + status: Option, + /// The keystroke interceptor that routes every key to the capture logic + /// while a row is capturing (see the capture notes above); it lives for + /// the tab's whole lifetime and only acts while `capturing` is set. + #[allow(dead_code)] // kept alive: dropping it unregisters the keystroke interceptor + interceptor: Option, +} + +impl KeyboardTabContent { + /// Builds the tab, seeding one row per menu-bar action (the C++ + /// `setup_kbd_shortcuts` enumeration). + pub fn new(_window: &mut Window, cx: &mut Context) -> Self { + let query = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)); + cx.subscribe(&query, |this, _query, _event: &TextChanged, cx| { + this.filter = this.query.read(cx).as_str().to_string(); + this.selected = None; + cx.notify(); + }) + .detach(); + let rows = crate::app::menu_action_paths() + .into_iter() + .map(|(action, path)| { + let section = path.split(" > ").next().unwrap_or_default().to_string(); + KeyboardRow { + action, + section, + path, + focus: cx.focus_handle(), + } + }) + .collect(); + let weak = cx.weak_entity(); + let interceptor = cx.intercept_keystrokes(move |event, _window, app| { + // While any row is capturing, handle the key here — before the + // shell's global key bindings (which would otherwise swallow it) — + // and stop the event so it can neither reach an app action nor + // bubble to the modal (Escape must cancel the capture, not close + // the dialog). + let Some(this) = weak.upgrade() else { + return; + }; + if this.read(app).capturing.is_some() { + this.update(app, |this, cx| { + this.handle_capture_key(&event.keystroke, cx); + }); + } + }); + Self { + query, + rows, + filter: String::new(), + capturing: None, + selected: None, + confirm_reset_all: false, + status: None, + interceptor: Some(interceptor), + } + } + + /// The number of menu-bar actions listed (tests). + pub fn row_count(&self) -> usize { + self.rows.len() + } + + /// Enters capture mode for `index`: remembers the row and highlights it. + /// The keystroke interceptor installed at construction does the rest — it + /// sees every key before the shell's global bindings do, so the capture + /// works regardless of which element currently has focus. + fn begin_capture(&mut self, index: usize, cx: &mut Context) { + self.capturing = Some(index); + self.selected = Some(index); + self.confirm_reset_all = false; + cx.notify(); + } + + /// Leaves capture mode. + fn end_capture(&mut self, cx: &mut Context) { + self.capturing = None; + cx.notify(); + } + + /// Handles one captured key (called from the keystroke interceptor, so it + /// runs for every key while a row is capturing). + fn handle_capture_key(&mut self, keystroke: &Keystroke, cx: &mut Context) { + let Some(index) = self.capturing else { + return; + }; + match capture_decision(keystroke) { + CaptureDecision::Ignore => cx.stop_propagation(), + CaptureDecision::Cancel => { + self.end_capture(cx); + cx.stop_propagation(); + } + CaptureDecision::Clear => { + let action = self.rows[index].action; + crate::actions::set_custom_shortcut(action.entry().cpp_id, Vec::new()); + let _ = crate::actions::save_custom_shortcuts(); + self.status = Some(i18n::tr("preferences.keyboard.cleared").to_string()); + self.end_capture(cx); + cx.emit(KeyboardEvent::Changed); + cx.stop_propagation(); + } + CaptureDecision::Assign(canon) => { + let action = self.rows[index].action; + let stolen = crate::actions::steal_shortcut_for(action.entry(), &canon); + crate::actions::set_custom_shortcut(action.entry().cpp_id, vec![canon]); + let _ = crate::actions::save_custom_shortcuts(); + self.status = stolen.map(|previous| { + i18n::tr("preferences.keyboard.conflict") + .replace("{action}", i18n::tr(previous.entry().i18n_key)) + }); + self.end_capture(cx); + cx.emit(KeyboardEvent::Changed); + cx.stop_propagation(); + } + } + } + + /// Reset Selected: the selected row's action falls back to its registry + /// default keys. + fn reset_selected(&mut self, cx: &mut Context) { + let Some(index) = self.selected else { + return; + }; + let action = self.rows[index].action; + crate::actions::reset_custom_shortcut(action.entry().cpp_id); + let _ = crate::actions::save_custom_shortcuts(); + self.status = Some(i18n::tr("preferences.keyboard.reset").to_string()); + cx.emit(KeyboardEvent::Changed); + cx.notify(); + } + + /// Reset All: the first click arms an inline confirmation (the C++ + /// `QMessageBox` equivalent, kept inside the tab so the host modal + /// machinery stays untouched); the second applies it. + fn reset_all(&mut self, cx: &mut Context) { + if !self.confirm_reset_all { + self.confirm_reset_all = true; + self.capturing = None; + cx.notify(); + return; + } + crate::actions::reset_all_custom_shortcuts(); + let _ = crate::actions::save_custom_shortcuts(); + self.confirm_reset_all = false; + self.status = Some(i18n::tr("preferences.keyboard.reset_all_done").to_string()); + cx.emit(KeyboardEvent::Changed); + cx.notify(); + } + + /// Import: pick a `shortcuts` file, replace the overrides with its + /// contents (anything unlisted falls back to default, like the C++ field + /// walk), then save the effective state back to the configured location. + fn import_shortcuts(&mut self, cx: &mut Context) { + let receiver = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some(i18n::tr("preferences.keyboard.import").into()), + }); + cx.spawn(async move |this, cx| { + let Ok(Ok(Some(paths))) = receiver.await else { + return; + }; + let Some(path) = paths.first() else { + return; + }; + let result = crate::actions::load_custom_shortcuts_from(&path.to_string_lossy()); + this.update(cx, |this, cx| { + match result { + Ok(_) => { + this.status = + Some(i18n::tr("preferences.keyboard.imported").to_string()); + let _ = crate::actions::save_custom_shortcuts(); + } + Err(_) => { + this.status = + Some(i18n::tr("preferences.keyboard.import_failed").to_string()) + } + } + this.capturing = None; + this.confirm_reset_all = false; + cx.emit(KeyboardEvent::Changed); + cx.notify(); + }); + }) + .detach(); + } + + /// Export: write the current override diff to a picked file. + fn export_shortcuts(&mut self, cx: &mut Context) { + let receiver = cx.prompt_for_new_path( + &std::path::PathBuf::from("."), + Some("shortcuts"), + ); + cx.spawn(async move |this, cx| { + let Ok(Ok(Some(path))) = receiver.await else { + return; + }; + let result = crate::actions::save_custom_shortcuts_to(&path.to_string_lossy()); + this.update(cx, |this, cx| { + this.status = Some(match result { + Ok(_) => i18n::tr("preferences.keyboard.exported").to_string(), + Err(_) => i18n::tr("preferences.keyboard.export_failed").to_string(), + }); + cx.notify(); + }); + }) + .detach(); + } +} + +impl Render for KeyboardTabContent { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let weak = self.query.downgrade(); + let capturing = self.capturing; + let selected = self.selected; + + // The search box. + let search = div() + .rounded_md() + .border_1() + .border_color(colors.border) + .bg(colors.background) + .px_2() + .py_1() + .child(text_input("keyboard-search-input").state(weak).accepts_input(true)); + + // The grouped, filtered action list. + let mut list = div() + .id("keyboard-shortcut-list") + .flex() + .flex_col() + .max_h(px(340.0)) + .overflow_y_scroll(); + let mut shown_section: Option = None; + for (index, row) in self.rows.iter().enumerate() { + let label = i18n::tr(row.action.entry().i18n_key); + let shortcut = crate::actions::display_shortcut(row.action); + if !keyboard_filter_matches(label, &row.path, shortcut.as_deref(), &self.filter) { + continue; + } + if shown_section.as_deref() != Some(row.section.as_str()) { + list = list.child(section_header(&colors, row.section.clone().into())); + shown_section = Some(row.section.clone()); + } + let row_selected = selected == Some(index); + let is_capturing = capturing == Some(index); + let field_label: SharedString = if is_capturing { + i18n::tr("preferences.keyboard.capturing").into() + } else { + shortcut + .map(SharedString::from) + .unwrap_or_else(|| i18n::tr("preferences.keyboard.unbound").into()) + }; + let row_path = row.path.clone(); + let focus = row.focus.clone(); + list = list.child( + div() + .id(ElementId::named_usize("keyboard-shortcut-row", index)) + .flex() + .items_center() + .gap_2() + .px_1() + .py_0p5() + .rounded_md() + .bg(if row_selected { colors.selected } else { transparent() }) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.selected = Some(index); + cx.notify(); + })) + .child( + div() + .flex_1() + .flex_col() + .child(div().text_color(colors.text).child(label)) + .child( + div() + .text_color(colors.disabled) + .text_xs() + .child(row_path), + ), + ) + .child( + div() + .id(ElementId::named_usize("keyboard-shortcut-capture", index)) + .debug_selector(move || format!("keyboard-capture-{index}").into()) + .min_w(px(150.0)) + .px_2() + .py_0p5() + .rounded_md() + .border_1() + .border_color(if is_capturing { + colors.selected + } else { + colors.border + }) + .bg(colors.background) + .text_color(colors.text) + .cursor_pointer() + .track_focus(&focus) + .on_click(cx.listener( + move |this, _event, _window, cx| { + if this.capturing != Some(index) { + this.begin_capture(index, cx); + } + cx.stop_propagation(); + }, + )) + .child(field_label), + ), + ); + } + + // The footer: Import/Export on the left, Reset Selected/All on the + // right (the inline Reset-All confirmation replaces them when armed). + let footer = if self.confirm_reset_all { + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .text_color(colors.text) + .child(i18n::tr("preferences.keyboard.reset_all.confirm")), + ) + .child( + pill_button( + "prefs-keyboard-confirm-reset", + i18n::tr("preferences.keyboard.reset_all"), + colors.selected, + colors.selected_text, + ) + .on_click(cx.listener(|this, _event, _window, cx| this.reset_all(cx))), + ) + .child( + pill_button( + "prefs-keyboard-cancel-reset", + i18n::tr("dialog.cancel"), + colors.background, + colors.text, + ) + .on_click(cx.listener(|this, _event, _window, cx| { + this.confirm_reset_all = false; + cx.notify(); + })), + ) + } else { + div() + .flex() + .items_center() + .gap_2() + .child( + pill_button( + "prefs-keyboard-import", + i18n::tr("preferences.keyboard.import"), + colors.background, + colors.text, + ) + .on_click(cx.listener(|this, _event, _window, cx| { + this.import_shortcuts(cx) + })), + ) + .child( + pill_button( + "prefs-keyboard-export", + i18n::tr("preferences.keyboard.export"), + colors.background, + colors.text, + ) + .on_click(cx.listener(|this, _event, _window, cx| { + this.export_shortcuts(cx) + })), + ) + .child(div().flex_1()) + .child( + pill_button( + "prefs-keyboard-reset-selected", + i18n::tr("preferences.keyboard.reset_selected"), + colors.background, + colors.text, + ) + .on_click(cx.listener(|this, _event, _window, cx| { + this.reset_selected(cx) + })), + ) + .child( + pill_button( + "prefs-keyboard-reset-all", + i18n::tr("preferences.keyboard.reset_all"), + colors.background, + colors.text, + ) + .on_click(cx.listener(|this, _event, _window, cx| { + this.reset_all(cx) + })), + ) + }; + + div() + .flex() + .flex_col() + .gap_3() + .w_full() + .child(search) + .child( + div() + .flex() + .text_color(colors.disabled) + .text_xs() + .child(i18n::tr("preferences.keyboard.action")) + .child(div().flex_1()) + .child(i18n::tr("preferences.keyboard.shortcut")), + ) + .child(list) + .child(footer) + .child( + if let Some(status) = &self.status { + div() + .text_color(colors.disabled) + .text_xs() + .child(status.clone()) + } else { + div() + }, + ) + } +} + +/// The outcome of one captured keystroke. +#[derive(Debug)] +enum CaptureDecision { + /// A modifier-only key — keep capturing, ignore it. + Ignore, + /// Escape — cancel the capture without changing anything. + Cancel, + /// Backspace / Delete — clear the binding (unbind). + Clear, + /// A real key — the new binding, in canonical gpui keystroke form. + Assign(String), +} + +/// Decides what a capture field should do with a keystroke. Modifier-only +/// keys (a bare Shift / Ctrl / …) never bind; Backspace and Delete clear; +/// Escape cancels; anything else (with or without modifiers) becomes the new +/// binding. +fn capture_decision(keystroke: &Keystroke) -> CaptureDecision { + match keystroke.key.as_str() { + "escape" => CaptureDecision::Cancel, + "backspace" | "delete" => CaptureDecision::Clear, + // Modifier-only key presses (the parser represents a bare modifier as + // the modifier's own key name). + "shift" | "control" | "alt" | "cmd" | "super" | "win" | "fn" | "function" + | "secondary" | "platform" => CaptureDecision::Ignore, + "" => CaptureDecision::Ignore, + _ => CaptureDecision::Assign(keystroke.unparse()), + } +} + +/// Whether an action row survives the keyboard tab's search query: +/// case-insensitive match against the action label, the localized menu path, +/// or the effective shortcut label. +fn keyboard_filter_matches(label: &str, path: &str, shortcut: Option<&str>, query: &str) -> bool { + let query = query.trim(); + if query.is_empty() { + return true; + } + let query = query.to_lowercase(); + let label_lower = label.to_lowercase(); + let full_path = format!("{path} > {label}").to_lowercase(); + label_lower.contains(&query) + || full_path.contains(&query) + || shortcut.is_some_and(|s| s.to_lowercase().contains(&query)) +} + +// --------------------------------------------------------------------------- +// Action search (Help > Search Actions…, the `/` key) +// --------------------------------------------------------------------------- + +/// One item of the action search list. +struct ActionSearchItem { + action: ActionId, + /// The localized "Menu > Submenu > …" path. + path: String, +} + +/// The action search dialog content (the C++ `ActionSearch`): a search field +/// over every menu-bar action, live filtering, arrow-key selection and +/// Enter / double-click execution through the same dispatch path the menu +/// clicks take. Panel-context hotkeys (the `HIDDEN_MENU_ID` items) never +/// appear — they have no menu item, exactly like the C++ "only the menu bar" +/// enumeration. +pub struct ActionSearchContent { + query: Entity, + items: Vec, + filter: String, + selection: Option, + /// The keystroke interceptor that routes Up/Down/Enter to the list while + /// the dialog is open (see [`ActionSearchContent::new`]). + #[allow(dead_code)] // kept alive: dropping it unregisters the keystroke interceptor + interceptor: Option, +} + +/// A request the action search dialog emits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActionSearchEvent { + /// The user activated `action`; the host dispatches it (the same path the + /// menu clicks take) and closes the dialog. + Execute(ActionId), +} + +impl EventEmitter for ActionSearchContent {} + +impl ActionSearchContent { + /// Builds the dialog with every menu-bar action, subscribes to the search + /// field so filtering re-runs on every keystroke, and installs a keystroke + /// interceptor that routes Up / Down / Enter to the list. + /// + /// The interceptor is needed because the shell's global key bindings run + /// *before* the widget-level key handlers and would swallow Up/Down (they + /// are the GoToPrevCut/GoToNextCut keys) and Enter — the same reason the + /// Keyboard tab captures its keys through an interceptor. The dialog is + /// modal, so the only text input alive while the interceptor is active is + /// the search field; every other keystroke passes through untouched (in + /// the real app the IME delivers text to the focused input independently + /// of the key map, so typing keeps working). + pub fn new(_window: &mut Window, cx: &mut Context) -> Self { + let query = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)); + cx.subscribe(&query, |this, _query, _event: &TextChanged, cx| { + this.filter = this.query.read(cx).as_str().to_string(); + let visible: Vec = this + .items + .iter() + .enumerate() + .filter(|(_, item)| search_filter_matches(item.action, &item.path, &this.filter)) + .map(|(index, _)| index) + .collect(); + this.selection = selection_step(&visible, None, 1); + cx.notify(); + }) + .detach(); + let items = crate::app::menu_action_paths() + .into_iter() + .map(|(action, path)| ActionSearchItem { action, path }) + .collect(); + let weak = cx.weak_entity(); + let interceptor = cx.intercept_keystrokes(move |event, _window, app| { + // Only act on the keys the dialog owns; everything else (text + // for the search field, Escape for the modal, …) passes through. + if !matches!(event.keystroke.key.as_str(), "up" | "down" | "enter") { + return; + } + let Some(this) = weak.upgrade() else { + return; + }; + let mut stop = false; + this.update(app, |this, cx| match event.keystroke.key.as_str() { + "up" => { + this.move_selection(-1, cx); + stop = true; + } + "down" => { + this.move_selection(1, cx); + stop = true; + } + "enter" => { + this.execute(cx); + stop = true; + } + // Escape deliberately falls through to the modal's own handler + // (which closes the dialog); every other key passes to the + // search field. + _ => {} + }); + if stop { + app.stop_propagation(); + } + }); + Self { + query, + items, + filter: String::new(), + selection: None, + interceptor: Some(interceptor), + } + } + + /// The search field's focus handle (the host focuses it when the dialog + /// opens, so the search is keyboard-first from the start). + pub fn search_focus(&self, cx: &App) -> FocusHandle { + self.query.read(cx).focus_handle(cx) + } + + fn move_selection(&mut self, delta: i32, cx: &mut Context) { + let visible: Vec = self + .items + .iter() + .enumerate() + .filter(|(_, item)| search_filter_matches(item.action, &item.path, &self.filter)) + .map(|(index, _)| index) + .collect(); + self.selection = selection_step(&visible, self.selection, delta); + cx.notify(); + } + + /// The currently selected action (tests). + pub fn selected_action(&self) -> Option { + self.selection.and_then(|index| self.items.get(index)).map(|item| item.action) + } + + /// The current search filter (tests). + pub fn filter(&self) -> &str { + &self.filter + } + + fn execute(&mut self, cx: &mut Context) { + if let Some(index) = self.selection { + if let Some(item) = self.items.get(index) { + cx.emit(ActionSearchEvent::Execute(item.action)); + } + } + } +} + +impl Render for ActionSearchContent { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let weak = self.query.downgrade(); + let selection = self.selection; + let visible: Vec = self + .items + .iter() + .enumerate() + .filter(|(_, item)| search_filter_matches(item.action, &item.path, &self.filter)) + .map(|(index, _)| index) + .collect(); + + let list = if visible.is_empty() { + div() + .id("action-search-list") + .flex() + .flex_col() + .max_h(px(360.0)) + .overflow_y_scroll() + .child( + div() + .text_color(colors.disabled) + .text_xs() + .child(if self.items.is_empty() { + i18n::tr("actionsearch.no_actions") + } else { + i18n::tr("actionsearch.empty") + }), + ) + } else { + div() + .id("action-search-list") + .flex() + .flex_col() + .max_h(px(360.0)) + .overflow_y_scroll() + .children(visible.iter().map(|&index| { + let item = &self.items[index]; + let label = i18n::tr(item.action.entry().i18n_key); + let path = item.path.clone(); + let row_selected = selection == Some(index); + div() + .id(ElementId::named_usize("action-search-item", index)) + .flex() + .items_center() + .gap_2() + .px_2() + .py_0p5() + .rounded_md() + .bg(if row_selected { colors.selected } else { transparent() }) + .text_color(if row_selected { + colors.selected_text + } else { + colors.text + }) + .cursor_pointer() + .on_click(cx.listener(move |this, _event, _window, cx| { + this.selection = Some(index); + cx.notify(); + })) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener( + move |this, event: &gpui::MouseDownEvent, _window, cx| { + // Double-click executes. + if event.click_count >= 2 { + this.selection = Some(index); + this.execute(cx); + } + }, + ), + ) + .child( + div() + .flex_1() + .flex_col() + .child(div().child(label)) + .child( + div() + .text_xs() + .text_color(colors.disabled) + .child(format!("({path})")), + ), + ) + })) + }; + + div() + .id("action-search-root") + .flex() + .flex_col() + .gap_2() + .w_full() + .child( + div() + .rounded_md() + .border_1() + .border_color(colors.border) + .bg(colors.background) + .px_2() + .py_1() + .child(text_input("action-search-input").state(weak).accepts_input(true)), + ) + .child(list) + } +} + +/// Whether an action-search item survives the query: case-insensitive match +/// against the action label or the full "Menu > Submenu > action" path. +fn search_filter_matches(action: ActionId, path: &str, query: &str) -> bool { + let query = query.trim(); + if query.is_empty() { + return true; + } + let query = query.to_lowercase(); + let label = i18n::tr(action.entry().i18n_key); + label.to_lowercase().contains(&query) + || format!("{path} > {label}").to_lowercase().contains(&query) +} + +/// The next selected index when moving `delta` (±1) through `visible` (the +/// indices of the currently visible rows), wrapping around. `None` returns +/// the first (for a downward move) / last (for an upward move). +fn selection_step(visible: &[usize], current: Option, delta: i32) -> Option { + if visible.is_empty() { + return None; + } + let position = current.and_then(|c| visible.iter().position(|&v| v == c)); + let next = match position { + Some(pos) => (pos as i64 + delta as i64).rem_euclid(visible.len() as i64) as usize, + None if delta > 0 => 0, + None => visible.len() - 1, + }; + Some(visible[next]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn keystroke(key: &str) -> Keystroke { + gpui::Keystroke::parse(key).unwrap() + } + + #[test] + fn capture_decision_handles_all_shapes() { + assert!(matches!(capture_decision(&keystroke("escape")), CaptureDecision::Cancel)); + assert!(matches!( + capture_decision(&keystroke("backspace")), + CaptureDecision::Clear + )); + assert!(matches!( + capture_decision(&keystroke("delete")), + CaptureDecision::Clear + )); + // Bare modifiers never bind. + assert!(matches!( + capture_decision(&keystroke("shift")), + CaptureDecision::Ignore + )); + assert!(matches!( + capture_decision(&keystroke("control")), + CaptureDecision::Ignore + )); + assert!(matches!( + capture_decision(&keystroke("alt")), + CaptureDecision::Ignore + )); + // A real key (with or without modifiers) becomes the canonical binding. + match capture_decision(&keystroke("secondary-s")) { + CaptureDecision::Assign(canon) => { + assert_eq!( + canon, + gpui::Keystroke::parse("secondary-s").unwrap().unparse() + ); + } + other => panic!("expected assign, got {other:?}"), + } + } + + #[test] + fn keyboard_filter_matches_name_path_and_shortcut() { + assert!(keyboard_filter_matches("Save Project", "File", Some("⌘S"), "save")); + assert!(keyboard_filter_matches("Save Project", "File", Some("⌘S"), "file > save")); + // Shortcut matching is case-insensitive. + assert!(keyboard_filter_matches("Save Project", "File", Some("⌘S"), "⌘s")); + assert!(!keyboard_filter_matches("Save Project", "File", Some("⌘S"), "undo")); + // Empty query matches everything; rows without a shortcut match only + // by name/path. + assert!(keyboard_filter_matches("About Oak…", "Help", None, "")); + assert!(keyboard_filter_matches("About Oak…", "Help", None, "help")); + assert!(!keyboard_filter_matches("About Oak…", "Help", None, "⌘")); + } + + #[test] + fn search_filter_matches_label_or_path() { + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); + crate::i18n::set_language(crate::i18n::Language::EnUs); + assert!(search_filter_matches(ActionId::NewProject, "File", "new")); + assert!(search_filter_matches(ActionId::NewProject, "File", "file > new")); + assert!(!search_filter_matches(ActionId::NewProject, "File", "undo")); + assert!(search_filter_matches(ActionId::NewProject, "File", "")); + } + + #[test] + fn selection_step_wraps_and_respects_visibility() { + assert_eq!(selection_step(&[2, 5, 7], None, 1), Some(2)); + assert_eq!(selection_step(&[2, 5, 7], None, -1), Some(7)); + assert_eq!(selection_step(&[2, 5, 7], Some(2), 1), Some(5)); + assert_eq!(selection_step(&[2, 5, 7], Some(7), 1), Some(2)); + assert_eq!(selection_step(&[2, 5, 7], Some(2), -1), Some(7)); + assert_eq!(selection_step(&[], None, 1), None); + } + + #[test] + fn keyboard_rows_cover_the_menu_bar() { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap(); + let _lang = crate::i18n::lang_test_lock().lock().unwrap(); + crate::i18n::set_language(crate::i18n::Language::EnUs); + let rows = crate::app::menu_action_paths(); + assert!(!rows.is_empty()); + // Every listed action resolves to a registry entry with a menu item. + for (action, path) in &rows { + assert_ne!(action.menu_id(), crate::actions::HIDDEN_MENU_ID); + assert!(!path.is_empty(), "action {action:?} has an empty path"); + } + } +} diff --git a/src/i18n.rs b/src/i18n.rs index ec7bbb892..a1755eb62 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -453,6 +453,34 @@ const EN: &[(&str, &str)] = &[ ("preferences.audio.input.placeholder", "Select an input device…"), ("preferences.audio.default", "System Default"), ("preferences.hint", "The renderer backend applies to the render worker at the next launch; every other setting takes effect immediately and is saved on exit."), + // --- Preferences: the tabbed dialog host --- + ("preferences.tab.general", "General"), + ("preferences.tab.keyboard", "Keyboard"), + // --- Preferences: Keyboard tab --- + ("preferences.section.keyboard", "Keyboard"), + ("preferences.keyboard.search", "Search for action or shortcut"), + ("preferences.keyboard.action", "Action"), + ("preferences.keyboard.shortcut", "Shortcut"), + ("preferences.keyboard.click_to_edit", "Click to set shortcut…"), + ("preferences.keyboard.capturing", "Press keys… (Esc to cancel)"), + ("preferences.keyboard.unbound", "None"), + ("preferences.keyboard.import", "Import…"), + ("preferences.keyboard.export", "Export…"), + ("preferences.keyboard.reset_selected", "Reset Selected"), + ("preferences.keyboard.reset_all", "Reset All"), + ("preferences.keyboard.reset_all.confirm", "Are you sure you wish to reset all keyboard shortcuts to their defaults?"), + ("preferences.keyboard.conflict", "The shortcut is already bound to {action}; the binding has been moved."), + ("preferences.keyboard.cleared", "Shortcut cleared (action unbound)."), + ("preferences.keyboard.reset", "Shortcut reset to its default."), + ("preferences.keyboard.reset_all_done", "All shortcuts reset to their defaults."), + ("preferences.keyboard.imported", "Shortcuts imported successfully."), + ("preferences.keyboard.exported", "Shortcuts exported successfully."), + ("preferences.keyboard.import_failed", "Failed to open the file for reading."), + ("preferences.keyboard.export_failed", "Failed to open the file for writing."), + // --- Action search dialog --- + ("actionsearch.search_placeholder", "Search for action…"), + ("actionsearch.empty", "No matching actions"), + ("actionsearch.no_actions", "No actions available"), ("export.title", "Export Sequence"), ("export.format", "Format"), ("export.format.placeholder", "Select a format…"), @@ -893,6 +921,34 @@ const ZH: &[(&str, &str)] = &[ "preferences.hint", "渲染后端在下次启动渲染工作进程时生效;其余设置立即生效,并在退出时保存。", ), + // --- 偏好设置:标签页容器 --- + ("preferences.tab.general", "常规"), + ("preferences.tab.keyboard", "键盘"), + // --- 偏好设置:键盘页 --- + ("preferences.section.keyboard", "键盘"), + ("preferences.keyboard.search", "搜索动作或快捷键"), + ("preferences.keyboard.action", "动作"), + ("preferences.keyboard.shortcut", "快捷键"), + ("preferences.keyboard.click_to_edit", "点击设置快捷键…"), + ("preferences.keyboard.capturing", "按下按键…(Esc 取消)"), + ("preferences.keyboard.unbound", "无"), + ("preferences.keyboard.import", "导入…"), + ("preferences.keyboard.export", "导出…"), + ("preferences.keyboard.reset_selected", "重置选中项"), + ("preferences.keyboard.reset_all", "重置全部"), + ("preferences.keyboard.reset_all.confirm", "确定要将所有键盘快捷键重置为默认值吗?"), + ("preferences.keyboard.conflict", "该快捷键已被 {action} 占用;绑定已转移。"), + ("preferences.keyboard.cleared", "快捷键已清除(动作已取消绑定)。"), + ("preferences.keyboard.reset", "快捷键已重置为默认值。"), + ("preferences.keyboard.reset_all_done", "所有快捷键已重置为默认值。"), + ("preferences.keyboard.imported", "快捷键导入成功。"), + ("preferences.keyboard.exported", "快捷键导出成功。"), + ("preferences.keyboard.import_failed", "无法打开文件读取。"), + ("preferences.keyboard.export_failed", "无法打开文件写入。"), + // --- 动作搜索对话框 --- + ("actionsearch.search_placeholder", "搜索动作…"), + ("actionsearch.empty", "无匹配动作"), + ("actionsearch.no_actions", "没有可用动作"), ("export.title", "导出序列"), ("export.format", "格式"), ("export.format.placeholder", "选择格式…"),