feat(app): custom shortcuts, Keyboard preferences tab, Action Search
- Shortcut override layer over the action registry: <config>/shortcuts file (id<TAB>keystroke, 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.
This commit is contained in:
+462
-8
@@ -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<KeyBinding> {
|
||||
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<KeyBinding> {
|
||||
/// 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<String> {
|
||||
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<String> {
|
||||
Some(label)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom shortcut overrides (the `<config>/shortcuts` file)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The C++ `MainWindow` keeps a `<config>/shortcuts` text file of
|
||||
// `id<TAB>keys` 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<String>),
|
||||
}
|
||||
|
||||
/// The process-global override table, keyed by the stable cpp id.
|
||||
static OVERRIDES: OnceLock<Mutex<HashMap<String, ShortcutOverride>>> = OnceLock::new();
|
||||
|
||||
/// The global override table.
|
||||
fn overrides() -> &'static Mutex<HashMap<String, ShortcutOverride>> {
|
||||
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<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
/// The path of the custom-shortcuts file: `<config>/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<String>)> {
|
||||
// `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<String>)> {
|
||||
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>)]) -> 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<String>)>) {
|
||||
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<String>) {
|
||||
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<String> {
|
||||
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<String>`
|
||||
/// overrides share one code path).
|
||||
fn canonical_keys<K: AsRef<str>>(keys: &[K]) -> Vec<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
canonical_keys(entry.default_keys)
|
||||
}
|
||||
|
||||
/// Loads `<config>/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<usize, String> {
|
||||
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<usize, String> {
|
||||
let mut lines: Vec<(String, Vec<String>)> = 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<usize, String> {
|
||||
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<ActionId> {
|
||||
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<ActionId> {
|
||||
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::<String>::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();
|
||||
}
|
||||
}
|
||||
|
||||
+417
-9
@@ -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<E: AppEngine> {
|
||||
None,
|
||||
Preferences {
|
||||
modal: Entity<Modal>,
|
||||
content: Entity<PreferencesContent>,
|
||||
content: Entity<PreferencesDialogContent>,
|
||||
},
|
||||
Export {
|
||||
modal: Entity<Modal>,
|
||||
@@ -162,6 +164,11 @@ enum ModalState<E: AppEngine> {
|
||||
modal: Entity<Modal>,
|
||||
content: Entity<crate::dialogs::ProxyDialogContent<E>>,
|
||||
},
|
||||
/// The action search dialog (Help > Search Actions…, the `/` key).
|
||||
ActionSearch {
|
||||
modal: Entity<Modal>,
|
||||
content: Entity<crate::dialogs::ActionSearchContent>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A running export: the session the tick loop drains for progress.
|
||||
@@ -180,7 +187,8 @@ impl<E: AppEngine> ModalState<E> {
|
||||
| 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<E: AppEngine> OakApp<E> {
|
||||
.detach();
|
||||
|
||||
// --- keyboard map ---------------------------------------------------
|
||||
// Register every registry default key as a global binding (context
|
||||
// Load the persisted custom shortcut overrides (`<config>/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<E: AppEngine> OakApp<E> {
|
||||
| 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<E: AppEngine> OakApp<E> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Self>) {
|
||||
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>) {
|
||||
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<E: AppEngine> OakApp<E> {
|
||||
this.rebuild_menu_bar(cx);
|
||||
cx.notify();
|
||||
}
|
||||
crate::dialogs::PreferencesEvent::ShortcutsChanged => {
|
||||
this.rebind_keys(cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
@@ -1738,6 +1774,83 @@ impl<E: AppEngine> OakApp<E> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Self>) {
|
||||
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<Self>) {
|
||||
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>) {
|
||||
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<MenuBarEntry> {
|
||||
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
|
||||
|
||||
+1087
-4
File diff suppressed because it is too large
Load Diff
+56
@@ -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", "选择格式…"),
|
||||
|
||||
Reference in New Issue
Block a user