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:
2026-08-18 23:09:53 +08:00
parent ec7b7e6d13
commit e0aa597f1e
4 changed files with 2022 additions and 21 deletions
+462 -8
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -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", "选择格式…"),