fix(gpui): restore configurable macOS shortcuts and normal quit (#248)
Restore configurable macOS Command aliases, preserve RenameTab rebinding and dispatch Command-Q through the normal quit path. Register Command aliases through the existing runtime platform entry without changing platform budgets or validation rules. Fixes #238.
This commit is contained in:
1 parent
3ad289e3e2
commit
ba31b0d9c3
4 files changed
+424
-35
No files matched your search
@@ -0,0 +1,116 @@
|
||||
# macOS command-key aliases
|
||||
|
||||
## Status
|
||||
|
||||
Implemented for issue #238.
|
||||
|
||||
## Context
|
||||
|
||||
The GPUI shell registered a macOS ⌘ shortcut set that existed nowhere else.
|
||||
`bind_macos_command_keys` hand-wrote roughly two dozen bindings after
|
||||
`default_workspace_bindings`, while `display::keymap::default_shortcuts()` — the
|
||||
table that `clear_action` / `reset_action`, the settings keymap page and the
|
||||
keybind reload path read — knew nothing about them. Half of those keys also
|
||||
appear in the config macOS platform table (`config/bindings/defaults.rs`); the
|
||||
other half only existed in the shell.
|
||||
|
||||
Two user-visible consequences followed. Clearing the Shell picker row wrote only
|
||||
`Ctrl+K:ReceiveChar`, so the static ⌘K binding kept consuming the key and the
|
||||
panel still opened. `Action::Quit`, declared for macOS as `"q", SUPER`, fell into
|
||||
the `_ => None` arm of `workspace_binding_in_context`, so ⌘Q did nothing at all
|
||||
(GPUI installs no application menu). The display layer also rendered the super
|
||||
modifier as `Win+`, so the settings page and the lines the app wrote into
|
||||
`pebrel_settings.txt` showed `Ctrl+Win+F` / `Win+F` on macOS.
|
||||
|
||||
## Evidence
|
||||
|
||||
- Issue #238 reproduction, with the settings file the released build wrote:
|
||||
`keybind=Ctrl+Win+F:ReceiveChar`, `keybind=Ctrl+K:ReceiveChar`,
|
||||
`keybind=shift+win+k:ReceiveChar` — the unbind persisted, yet ⌘K still opened
|
||||
the picker.
|
||||
- `display::keymap` regressions (`clearing_releases_the_macos_command_alias`,
|
||||
`macos_command_aliases_round_trip_through_storage`,
|
||||
`super_modifier_renders_per_platform`) and shell regressions
|
||||
(`every_macos_command_alias_binds_a_gpui_action`,
|
||||
`released_cmd_k_is_swallowed_by_no_action`).
|
||||
- GPUI keymap semantics: a no-action binding for a keystroke suppresses matching
|
||||
bindings from equal or weaker sources, and a keystroke with no remaining
|
||||
binding falls through to the view's key handler — that is why a cleared ⌘ key
|
||||
reaches the terminal instead of being eaten.
|
||||
- Manual acceptance on macOS 26 (arm64, debug build, window driven through
|
||||
accessibility): default ⌘K opens the picker; clearing the row writes both
|
||||
`Ctrl+K:ReceiveChar` and `cmd+k:ReceiveChar` and releases ⌘K and Ctrl+K without
|
||||
a restart; restoring the row brings ⌘K back; ⌘Q quits with `clean_exit` true.
|
||||
|
||||
## Decision
|
||||
|
||||
`display::keymap::MACOS_COMMAND_ALIASES` is the single authority for the shell's
|
||||
⌘ set. `init()` derives the static gpui bindings from it, and
|
||||
`default_shortcuts()` adds it to the unbind/restore vocabulary on macOS, deduped
|
||||
against entries the config table already provides for the same action and key.
|
||||
The alias table writes `cmd+…` into `keybind=` lines. Existing files and shortcut
|
||||
capture can still use `win+…`, and manually edited files can use `super+…` or a
|
||||
different modifier order. The GPUI adapter parses and canonicalizes its binding
|
||||
strings before comparing overrides and restored defaults. Display spelling must
|
||||
not determine a shortcut's runtime identity.
|
||||
|
||||
`Action::Quit` maps to a `QuitApp` action whose handler defers
|
||||
`windowing::quit_all`, the same path as tray quit, so ⌘Q saves the session and
|
||||
drafts before stopping PTYs. `mods_prefix` renders the super modifier per
|
||||
platform, which also corrects the spelling of lines the app writes.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- Showing the ⌘ aliases on the settings keymap page (reverse-looking-up the
|
||||
alias table in `effective_combo`) changes about twenty keycaps on macOS, the
|
||||
shadowing verdicts other rows derive from them, and needs its own visual
|
||||
acceptance. The page keeps showing the config table's Ctrl key.
|
||||
- Moving the ⌘ set into the config macOS platform table would feed the legacy
|
||||
terminal shell UI-only actions and keep the ⌘K collision, which the table
|
||||
already resolves differently (`Esc("\x0c")` plus `ClearHistory`).
|
||||
- Comparing raw display/storage spellings fails for equivalent `Win+…`,
|
||||
`Cmd+…` and `super+…` keys. Display text is not a canonical identity;
|
||||
`Ctrl++`-style display spellings also do not parse back as storage strings.
|
||||
- Mapping the removed static ⌘ bindings to `ReceiveChar` from `init()` was not
|
||||
needed: the user keybind table already injects the no-action binding.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Cleared actions now also write `keybind=cmd+…:ReceiveChar` lines. Users coming
|
||||
from an earlier build must clear the row once more, because their existing file
|
||||
predates the alias vocabulary and holds no line for the ⌘ half.
|
||||
- The keymap page still shows Ctrl keys for actions whose macOS binding set
|
||||
includes ⌘. Releasing them works; seeing them does not.
|
||||
- ⌘K after unbinding does not clear the screen. The GPUI shell has no
|
||||
clear-screen action (`Action::Esc` and `Action::ClearHistory` are unmapped) and
|
||||
the terminal encoder ignores pure ⌘ combinations, so the key is released but
|
||||
inert. Out of scope here; `Ctrl+L` still passes through to the shell.
|
||||
- ⌘B and ⌘, stay static bindings: they have no editable keymap row, so there is
|
||||
nothing to clear.
|
||||
|
||||
## Validation
|
||||
|
||||
`cargo fmt --all -- --check`, `python3 scripts/check_architecture.py --base
|
||||
f7ca0ec`, `python3 scripts/check_platform_cfg.py` (budget unchanged at 484) and
|
||||
the `display::keymap` / `keyboard_bindings` unit suites pass; the two new
|
||||
released-key tests fail without the change. Manual acceptance is listed under
|
||||
Evidence. The alias table is shared data; runtime registration uses the existing
|
||||
`Platform::current()` entry and remains macOS-only. The two macOS restore
|
||||
regressions remain platform-gated. This avoids duplicating platform compile
|
||||
branches in the UI adapter without increasing the current cfg budget.
|
||||
A cross-platform GPUI dispatch regression confirms that sharing the table does
|
||||
not register Command shortcuts on Windows/Linux.
|
||||
|
||||
The 2026-09-23 review reproduced two restore failures through GPUI keyboard
|
||||
dispatch: recording Cmd+K then resetting the row, and resetting the old
|
||||
`Ctrl+Win+F:ReceiveChar` override from #238. Both failed before canonicalization.
|
||||
Regression coverage includes those paths and equivalent modifier spellings/order.
|
||||
|
||||
## Supersedes
|
||||
|
||||
None.
|
||||
|
||||
## Revisit when
|
||||
|
||||
The keymap page should show platform-native keycaps, or macOS ⌘K should be
|
||||
assigned to a clear-screen action instead of the Shell picker.
|
||||
@@ -88,6 +88,52 @@ pub(crate) const READONLY_ROWS: &[(&str, &str, &str)] = &[
|
||||
("贴入 AI 修复建议", "Paste AI fix suggestion", "Ctrl+."),
|
||||
];
|
||||
|
||||
/// macOS 的 ⌘ 命令键别名:`(存储写法, 动作)`。
|
||||
///
|
||||
/// GPUI 壳在配置表之外**追加**一整套 ⌘ 绑定:配置表的 macOS 段只覆盖其中
|
||||
/// 一半,另一半(⌘K、⌘D、⌘⇧P…)是壳自己加的。这张表是那套绑定的唯一权威:
|
||||
/// `init()` 按它注册静态绑定,解绑与恢复按它写回 `keybind=` 行。留在表外
|
||||
/// 就成了影子绑定——`clear_action` 不知道它,于是出现「⌘K 解绑后仍然弹出
|
||||
/// Shell 选择器」(#238)。
|
||||
///
|
||||
/// 表里的写法会写进设置文件,超级键写 `cmd`(macOS 的读法)。历史配置和
|
||||
/// 录制结果仍可使用 `win` / `super`;GPUI 适配层按解析后的按键规范化,
|
||||
/// 覆盖与恢复默认不依赖这些别名或修饰键顺序的逐字相等。
|
||||
///
|
||||
/// 这张表只进 [`default_shortcuts`](解绑/恢复口径),不进 `effective_combo`
|
||||
/// 的键帽反查:设置页仍按配置表的 Ctrl 键显示。
|
||||
///
|
||||
/// 别名表是共享数据;实际注册由平台层入口限定为 macOS,便于跨平台核验分派。
|
||||
pub(crate) const MACOS_COMMAND_ALIASES: &[(&str, Action)] = &[
|
||||
("cmd+t", Action::CreateNewTab),
|
||||
("cmd+n", Action::CreateNewWindow),
|
||||
// ⌘W 关标签页是 GPUI 壳的实际行为;配置表的 macOS 段把 ⌘W 记作 Quit,
|
||||
// 这里必须压过它,否则解绑恢复会把 ⌘W 还给退出。
|
||||
("cmd+w", Action::CloseTab),
|
||||
("shift+cmd+p", Action::ToggleCommandPalette),
|
||||
("cmd+k", Action::ToggleShellPicker),
|
||||
("shift+cmd+f", Action::ToggleFilesPanel),
|
||||
("cmd+d", Action::SplitRight),
|
||||
("shift+cmd+d", Action::SplitDown),
|
||||
("shift+cmd+enter", Action::ToggleZoom),
|
||||
("alt+cmd+left", Action::FocusPaneLeft),
|
||||
("alt+cmd+right", Action::FocusPaneRight),
|
||||
("alt+cmd+up", Action::FocusPaneUp),
|
||||
("alt+cmd+down", Action::FocusPaneDown),
|
||||
("shift+cmd+]", Action::SelectNextTab),
|
||||
("shift+cmd+[", Action::SelectPreviousTab),
|
||||
("shift+cmd+g", Action::ToggleGitPanel),
|
||||
("cmd+plus", Action::IncreaseFontSize),
|
||||
("cmd+=", Action::IncreaseFontSize),
|
||||
("cmd+minus", Action::DecreaseFontSize),
|
||||
("cmd+digit0", Action::ResetFontSize),
|
||||
("cmd+c", Action::Copy),
|
||||
("cmd+v", Action::Paste),
|
||||
("ctrl+cmd+f", Action::ToggleFullscreen),
|
||||
("shift+cmd+o", Action::OpenQuickJump),
|
||||
("cmd+q", Action::Quit),
|
||||
];
|
||||
|
||||
pub(crate) fn action_label(
|
||||
row: &(Action, &'static str, &'static str),
|
||||
language: super::UiLanguage,
|
||||
@@ -118,13 +164,33 @@ fn cached_defaults() -> &'static [KeyBinding] {
|
||||
}
|
||||
|
||||
pub(crate) fn default_shortcuts() -> Vec<(String, Action)> {
|
||||
cached_defaults()
|
||||
let mut shortcuts: Vec<(String, Action)> = cached_defaults()
|
||||
.iter()
|
||||
.filter_map(|binding| {
|
||||
display_combo(binding.mods, &binding.trigger)
|
||||
.map(|combo| (combo, binding.action.clone()))
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
// macOS 的 ⌘ 键也算默认键:`clear_action` / `reset_action` 按这份表决定要
|
||||
// 解掉、要还回哪些键,不带上它就会漏掉 ⌘ 那一半(#238)。
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let mut extra: Vec<(String, Action)> = Vec::new();
|
||||
for (combo, action) in MACOS_COMMAND_ALIASES {
|
||||
let parsed = parse_combo(combo);
|
||||
// 配置表已经给了同一动作的同一个键(⌘T、⌘C、⌘Q…)就不重复追加:
|
||||
// 解绑会把每条 combo 都写进设置文件,重复条目只会多留一行废话。
|
||||
let covered = shortcuts
|
||||
.iter()
|
||||
.chain(extra.iter())
|
||||
.any(|(known, candidate)| candidate == action && parse_combo(known) == parsed);
|
||||
if !covered {
|
||||
extra.push(((*combo).to_owned(), action.clone()));
|
||||
}
|
||||
}
|
||||
shortcuts.extend(extra);
|
||||
}
|
||||
shortcuts
|
||||
}
|
||||
|
||||
/// Editing one action moves its shortcut: old keys pass through to the terminal.
|
||||
@@ -335,7 +401,10 @@ pub(crate) fn mods_prefix(mods: ModifiersState) -> String {
|
||||
out.push_str("Alt+");
|
||||
}
|
||||
if mods.super_key() {
|
||||
out.push_str("Win+");
|
||||
// macOS 的超级键是 ⌘。键帽必须写 Cmd+,否则设置页会出现
|
||||
// `Ctrl+Win+F`(全屏)、`Win+F`(搜索)这类用户认不出的组合(#238)。
|
||||
// 存储侧不受影响:`win+` / `super+` / `cmd+` 同等解析。
|
||||
out.push_str(if cfg!(target_os = "macos") { "Cmd+" } else { "Win+" });
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -805,4 +874,62 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 超级键键帽随平台渲染:macOS 是 ⌘,其它平台是 Win。
|
||||
#[test]
|
||||
fn super_modifier_renders_per_platform() {
|
||||
let expected = if cfg!(target_os = "macos") { "Cmd+" } else { "Win+" };
|
||||
assert_eq!(mods_prefix(ModifiersState::SUPER), expected);
|
||||
assert_eq!(
|
||||
mods_prefix(ModifiersState::CONTROL | ModifiersState::SUPER),
|
||||
format!("Ctrl+{expected}")
|
||||
);
|
||||
}
|
||||
|
||||
/// 别名条目要能在存储格式里往返:`clear_action` 把表里的写法逐字写进
|
||||
/// 设置文件,解析层必须读回同一个组合。
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_command_aliases_round_trip_through_storage() {
|
||||
for (combo, _) in MACOS_COMMAND_ALIASES {
|
||||
let (mods, key) = parse_combo(combo).unwrap_or_else(|| panic!("{combo} 解析失败"));
|
||||
assert!(mods.super_key(), "{combo} 必须是 ⌘ 组合");
|
||||
let canonical =
|
||||
canonical_combo(mods, &key).unwrap_or_else(|| panic!("{combo} 无法写成存储格式"));
|
||||
assert_eq!(parse_combo(&canonical), Some((mods, key)), "{combo} 存储往返不一致");
|
||||
}
|
||||
}
|
||||
|
||||
/// #238:macOS 上 Shell 选择器除了配置表的 Ctrl+K 还有一条壳自带的
|
||||
/// ⌘K。解绑必须把两条都解掉,否则静态默认绑定继续抢键,用户看到的就是
|
||||
/// 「解绑了但 ⌘K 照样弹面板」。
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn clearing_releases_the_macos_command_alias() {
|
||||
let action = Action::ToggleShellPicker;
|
||||
// 设置页仍按配置表的 Ctrl+K 展示:别名只进解绑口径,不进键帽反查。
|
||||
assert_eq!(effective_combo(&action, &[]), Some(("Ctrl+K".to_owned(), false)));
|
||||
let mut raw = Vec::new();
|
||||
clear_action(&mut raw, &action);
|
||||
// 解绑使用表里的写法;旧配置和录制结果的等价拼写由 GPUI 层规范化。
|
||||
assert!(
|
||||
raw.iter().any(|(combo, _)| combo == "cmd+k"),
|
||||
"⌘K 必须按表里的写法逐字写回,实际 {raw:?}"
|
||||
);
|
||||
// Ctrl+K 来自配置表、⌘K 来自别名表,两条都要落成 ReceiveChar。
|
||||
for combo in ["ctrl+k", "cmd+k"] {
|
||||
let parsed = parse_combo(combo);
|
||||
assert!(
|
||||
raw.iter().any(|(candidate, name)| parse_combo(candidate) == parsed
|
||||
&& parse_action(name) == Some(Action::ReceiveChar)),
|
||||
"{combo} 必须落成 ReceiveChar,否则运行时解不掉静态默认绑定"
|
||||
);
|
||||
}
|
||||
assert_eq!(effective_combo(&action, &build_bindings(&raw)), None, "解绑后不应再有有效键");
|
||||
reset_action(&mut raw, &action);
|
||||
assert!(
|
||||
effective_combo(&action, &build_bindings(&raw)).is_some(),
|
||||
"恢复要把两组默认键都还回来"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -44,22 +44,30 @@ pub(super) const STATIC_DEFAULT_COMBOS: &[&str] = &[
|
||||
/// 存储格式 combo(`ctrl+shift+t`)→ gpui 绑定串(`ctrl-shift-t`)。键名
|
||||
/// 两套体系同构(小写命名键 + 单字符);digitN 折回数字,plus/minus 折回
|
||||
/// `+`/`-`(`+` 是存储分隔符,必须先占位再替换)。
|
||||
/// 最后由 GPUI 解析并规范化修饰键别名和顺序,保证旧 Win+、新 Cmd+ 和
|
||||
/// 录制结果在覆盖/恢复默认时具有同一个运行时身份。
|
||||
pub(super) fn gpui_binding_combo(combo: &str) -> String {
|
||||
combo
|
||||
let combo = combo
|
||||
.to_ascii_lowercase()
|
||||
.replace("plus", "\u{1}")
|
||||
.replace("minus", "\u{2}")
|
||||
.replace('+', "-")
|
||||
.replace("digit", "")
|
||||
.replace('\u{1}', "+")
|
||||
.replace('\u{2}', "-")
|
||||
.replace('\u{2}', "-");
|
||||
gpui::Keystroke::parse(&combo).map(|key| key.unparse()).unwrap_or(combo)
|
||||
}
|
||||
|
||||
/// 注册工作区快捷键;在 `gpui_component::init` 之后调用一次。
|
||||
pub(super) fn init(cx: &mut App) {
|
||||
cx.bind_keys(default_workspace_bindings());
|
||||
#[cfg(target_os = "macos")]
|
||||
bind_macos_command_keys(cx);
|
||||
// 平台判定复用已有入口;共享表不意味着给其他平台注册 ⌘ 快捷键。
|
||||
if crate::platform::Platform::current() == crate::platform::Platform::MacOS {
|
||||
bind_macos_command_keys(cx);
|
||||
}
|
||||
// 退出不属于任何视图,挂全局兜底:⌘Q 与用户自定义的 `keybind=…:Quit`
|
||||
// 都落到与托盘退出同一条「先落盘会话与草稿,再停 PTY」的路径。
|
||||
cx.on_action(|_: &QuitApp, cx: &mut App| cx.defer(super::windowing::quit_all));
|
||||
}
|
||||
|
||||
/// 工作区静态默认键位表;与 [`STATIC_DEFAULT_COMBOS`] 互为镜像。
|
||||
@@ -132,37 +140,23 @@ pub(super) fn default_workspace_bindings() -> Vec<KeyBinding> {
|
||||
/// 追加而非替换有两个原因:Ctrl+Shift 组合在 Mac 终端里没有别的含义,留着
|
||||
/// 不碍事;而 ⌘C/⌘V 必须存在,否则 Mac 用户第一反应就是「复制粘贴坏了」。
|
||||
/// 终端里的 Ctrl+C 仍然是 SIGINT——这里只绑 ⌘,不碰 Ctrl 的语义。
|
||||
#[cfg(target_os = "macos")]
|
||||
///
|
||||
/// 键位来自 `display::keymap::MACOS_COMMAND_ALIASES`:设置页的反查、解绑与
|
||||
/// 恢复读的是同一张表,两处不会再各自漂移。注册必须留在这里、且早于用户
|
||||
/// 自定义键,这样 `clear_action` 注入的 NoAction 才压得住静态默认绑定。
|
||||
fn bind_macos_command_keys(cx: &mut App) {
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("cmd-t", NewTerminal, None),
|
||||
KeyBinding::new("cmd-n", NewWindow, None),
|
||||
KeyBinding::new("cmd-w", CloseActiveTerminal, None),
|
||||
let mut bindings: Vec<KeyBinding> = crate::display::keymap::MACOS_COMMAND_ALIASES
|
||||
.iter()
|
||||
.filter_map(|(combo, action)| workspace_binding_in_context(combo, action, None))
|
||||
.collect();
|
||||
// 剩下这些没有对应的 `config::Action`,或者需要单独的作用域。
|
||||
bindings.extend([
|
||||
KeyBinding::new("cmd-b", ToggleSidebar, None),
|
||||
KeyBinding::new("cmd-,", OpenSettings, None),
|
||||
KeyBinding::new("cmd-shift-p", ToggleCommandPalette, None),
|
||||
KeyBinding::new("cmd-k", ToggleShellPicker, None),
|
||||
KeyBinding::new("cmd-shift-f", ToggleFileTree, None),
|
||||
KeyBinding::new("cmd-d", SplitRight, None),
|
||||
KeyBinding::new("cmd-shift-d", SplitDown, None),
|
||||
KeyBinding::new("cmd-shift-enter", ToggleZoom, None),
|
||||
KeyBinding::new("cmd-alt-left", FocusPaneLeft, None),
|
||||
KeyBinding::new("cmd-alt-right", FocusPaneRight, None),
|
||||
KeyBinding::new("cmd-alt-up", FocusPaneUp, None),
|
||||
KeyBinding::new("cmd-alt-down", FocusPaneDown, None),
|
||||
KeyBinding::new("cmd-shift-]", SelectNextTab, None),
|
||||
KeyBinding::new("cmd-shift-[", SelectPreviousTab, None),
|
||||
KeyBinding::new("cmd-shift-g", ToggleGitPanel, None),
|
||||
KeyBinding::new("cmd-=", IncreaseFontSize, None),
|
||||
KeyBinding::new("cmd-+", IncreaseFontSize, None),
|
||||
KeyBinding::new("cmd--", DecreaseFontSize, None),
|
||||
KeyBinding::new("cmd-0", ResetFontSize, None),
|
||||
KeyBinding::new("cmd-c", CopySelection, Some(crate::gpui_shell::terminal::KEY_CONTEXT)),
|
||||
KeyBinding::new("cmd-v", PasteClipboard, Some(crate::gpui_shell::terminal::KEY_CONTEXT)),
|
||||
// 设置页与对话框的输入框自带 ⌘V,作用域必须和终端分开。
|
||||
KeyBinding::new("cmd-v", gpui_component::input::Paste, Some("Input")),
|
||||
KeyBinding::new("cmd-ctrl-f", ToggleFullscreen, None),
|
||||
KeyBinding::new("cmd-shift-o", OpenQuickJump, None),
|
||||
]);
|
||||
cx.bind_keys(bindings);
|
||||
}
|
||||
|
||||
/// Typed GPUI adapter for the shared numbered/last-tab actions. The existing
|
||||
@@ -173,6 +167,12 @@ pub(super) struct SelectTab {
|
||||
index: Option<usize>,
|
||||
}
|
||||
|
||||
/// 退出应用,macOS 上绑 ⌘Q(`Action::Quit` 的 GPUI 侧落点)。处理注册在
|
||||
/// [`init`] 的全局兜底上,与绑定同一处维护。
|
||||
#[derive(Clone, Debug, PartialEq, gpui::Action)]
|
||||
#[action(namespace = nebula_workspace, no_json)]
|
||||
pub(super) struct QuitApp;
|
||||
|
||||
impl SelectTab {
|
||||
fn from_config(action: &crate::config::Action) -> Option<Self> {
|
||||
use crate::config::Action;
|
||||
@@ -250,8 +250,9 @@ fn workspace_binding_in_context(
|
||||
)),
|
||||
Action::ToggleFullscreen => Some(KeyBinding::new(&combo, ToggleFullscreen, scope)),
|
||||
Action::OpenQuickJump => Some(KeyBinding::new(&combo, OpenQuickJump, scope)),
|
||||
// Mask the app action. ReceiveChar entries from the editor allow
|
||||
// the terminal input handler to encode the released key.
|
||||
// macOS 的退出键走与托盘退出同一条路径:先落盘会话与草稿,再停 PTY。
|
||||
Action::Quit => Some(KeyBinding::new(&combo, QuitApp, scope)),
|
||||
// 屏蔽应用动作后,ReceiveChar 仍交给终端编码,保留改键释放旧键的语义。
|
||||
Action::None | Action::ReceiveChar => Some(KeyBinding::new(&combo, gpui::NoAction, scope)),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,90 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn command_aliases_and_modifier_order_share_one_runtime_identity() {
|
||||
let expected = gpui_binding_combo("shift+cmd+k");
|
||||
for combo in ["Shift+Win+K", "cmd+shift+k", "super+shift+k"] {
|
||||
assert_eq!(gpui_binding_combo(combo), expected, "{combo}");
|
||||
}
|
||||
assert_ne!(gpui_binding_combo("cmd+k"), expected);
|
||||
assert_ne!(gpui_binding_combo("ctrl+shift+k"), expected);
|
||||
}
|
||||
|
||||
mod macos_command_keys {
|
||||
use super::*;
|
||||
|
||||
/// #238:别名表里的每条都必须落到 gpui 绑定上。表和运行时各写一份时,
|
||||
/// 设置页显示的键可能按下去没有反应;合一之后这条守住两边不再漂移。
|
||||
#[test]
|
||||
fn every_macos_command_alias_binds_a_gpui_action() {
|
||||
for (combo, action) in crate::display::keymap::MACOS_COMMAND_ALIASES {
|
||||
assert!(
|
||||
workspace_binding_in_context(combo, action, None).is_some(),
|
||||
"{combo} → {action:?} 没有对应的 gpui 绑定"
|
||||
);
|
||||
}
|
||||
// ⌘Q 必须落到退出动作,否则退出键不会走保存会话与草稿的路径。
|
||||
let quit = workspace_binding_in_context("cmd+q", &crate::config::Action::Quit, None)
|
||||
.expect("⌘Q 必须有绑定");
|
||||
assert!(quit.action().as_any().is::<QuitApp>(), "⌘Q 必须绑定 QuitApp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_close_and_quit_dispatch_to_distinct_actions() {
|
||||
use gpui::{KeyContext, Keymap, Keystroke};
|
||||
// 合并后仍按共享别名表分派,防止配置中的 ⌘W/Quit 抢走关闭标签页动作。
|
||||
let bindings = crate::display::keymap::MACOS_COMMAND_ALIASES
|
||||
.iter()
|
||||
.filter_map(|(combo, action)| workspace_binding_in_context(combo, action, None))
|
||||
.collect();
|
||||
let keymap = Keymap::new(bindings);
|
||||
for context in ["Root", crate::gpui_shell::terminal::KEY_CONTEXT] {
|
||||
let contexts = [KeyContext::parse(context).unwrap()];
|
||||
for (combo, quit) in [("cmd-w", false), ("cmd-q", true)] {
|
||||
let input = [Keystroke::parse(combo).unwrap()];
|
||||
let (bindings, pending) = keymap.bindings_for_input(&input, &contexts);
|
||||
assert!(!pending);
|
||||
let action = bindings.first().expect("命令键必须有明确动作").action();
|
||||
if quit {
|
||||
assert!(action.as_any().is::<QuitApp>());
|
||||
} else {
|
||||
assert!(action.as_any().is::<CloseActiveTerminal>());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #238:⌘K 被解绑后,用户注入的 NoAction 必须压得住静态默认绑定。
|
||||
#[test]
|
||||
fn released_cmd_k_is_swallowed_by_no_action() {
|
||||
use crate::config::Action;
|
||||
use gpui::{KeyContext, Keymap, Keystroke};
|
||||
let contexts = [
|
||||
KeyContext::parse("Root").unwrap(),
|
||||
KeyContext::parse(crate::gpui_shell::terminal::KEY_CONTEXT).unwrap(),
|
||||
];
|
||||
let input = [Keystroke::parse("cmd-k").unwrap()];
|
||||
// 反向对照:只有静态默认键时 ⌘K 必须命中 Shell 选择器,否则本用例空跑。
|
||||
let default =
|
||||
custom_workspace_binding("cmd+k", &Action::ToggleShellPicker).expect("静态默认键");
|
||||
let (bindings, pending) =
|
||||
Keymap::new(vec![default.clone()]).bindings_for_input(&input, &contexts);
|
||||
assert!(!pending, "对照组必须命中");
|
||||
assert!(bindings[0].action().as_any().is::<ToggleShellPicker>());
|
||||
// `update_keybinds` 在两个作用域各注入一条 NoAction,这里照抄运行时形状。
|
||||
let clear = workspace_binding_in_context("cmd+k", &Action::ReceiveChar, None).unwrap();
|
||||
let clear_terminal = workspace_binding_in_context(
|
||||
"cmd+k",
|
||||
&Action::ReceiveChar,
|
||||
Some(crate::gpui_shell::terminal::KEY_CONTEXT),
|
||||
)
|
||||
.unwrap();
|
||||
let released = Keymap::new(vec![default, clear, clear_terminal]);
|
||||
let (bindings, pending) = released.bindings_for_input(&input, &contexts);
|
||||
assert!(bindings.is_empty() && !pending, "解绑后不应有任何应用动作消费 ⌘K");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleared_shortcut_reaches_terminal_and_can_be_restored_without_restart() {
|
||||
use crate::config::Action;
|
||||
@@ -120,6 +206,65 @@ mod dispatch {
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn command_shortcut_registration_follows_runtime_platform(cx: &mut TestAppContext) {
|
||||
let (_directory, workspace, mut cx) = open_workspace(1, cx);
|
||||
press("cmd-k", &mut cx);
|
||||
assert_eq!(
|
||||
workspace.read_with(&cx, |workspace, _| workspace.shell_picker_open),
|
||||
crate::platform::Platform::current() == crate::platform::Platform::MacOS,
|
||||
"共享别名表只应在 macOS 注册原生命令键"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn recorded_command_key_can_restore_default(cx: &mut TestAppContext) {
|
||||
use crate::display::keymap;
|
||||
let (_directory, workspace, mut cx) = open_workspace(1, cx);
|
||||
press("cmd-k", &mut cx);
|
||||
assert!(workspace.read_with(&cx, |workspace, _| workspace.shell_picker_open));
|
||||
press("escape", &mut cx);
|
||||
let keymap::CaptureOutcome::Bind(combo) =
|
||||
keymap::capture_gpui(&Keystroke::parse("cmd-k").unwrap())
|
||||
else {
|
||||
panic!("expected captured shortcut")
|
||||
};
|
||||
assert_eq!(combo, "win+k");
|
||||
let mut raw = vec![(combo, "ToggleShellPicker".into())];
|
||||
workspace.update(&mut cx, |workspace, cx| workspace.update_keybinds(raw.clone(), cx));
|
||||
press("cmd-k", &mut cx);
|
||||
assert!(workspace.read_with(&cx, |workspace, _| workspace.shell_picker_open));
|
||||
press("escape", &mut cx);
|
||||
keymap::reset_action(&mut raw, &crate::config::Action::ToggleShellPicker);
|
||||
workspace.update(&mut cx, |workspace, cx| workspace.update_keybinds(raw, cx));
|
||||
press("cmd-k", &mut cx);
|
||||
assert!(
|
||||
workspace.read_with(&cx, |workspace, _| workspace.shell_picker_open),
|
||||
"Reset after recording Cmd+K must restore its default action"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn old_fullscreen_unbind_can_restore_default(cx: &mut TestAppContext) {
|
||||
use crate::display::keymap;
|
||||
let (_directory, workspace, mut cx) = open_workspace(1, cx);
|
||||
for combo in ["Ctrl+Cmd+F", "Ctrl+Win+F", "super+ctrl+f"] {
|
||||
let mut raw = vec![(combo.into(), "ReceiveChar".into())];
|
||||
workspace.update(&mut cx, |workspace, cx| workspace.update_keybinds(raw.clone(), cx));
|
||||
press("ctrl-cmd-f", &mut cx);
|
||||
assert!(!cx.update(|window, _| window.is_fullscreen()));
|
||||
keymap::reset_action(&mut raw, &crate::config::Action::ToggleFullscreen);
|
||||
assert!(raw.is_empty());
|
||||
workspace.update(&mut cx, |workspace, cx| workspace.update_keybinds(raw, cx));
|
||||
press("ctrl-cmd-f", &mut cx);
|
||||
assert!(cx.update(|window, _| window.is_fullscreen()), "reset failed for {combo}");
|
||||
press("ctrl-cmd-f", &mut cx);
|
||||
assert!(!cx.update(|window, _| window.is_fullscreen()));
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn digits_switch_real_tabs_and_focus_in_both_tab_layouts(cx: &mut TestAppContext) {
|
||||
let (_directory, workspace, mut cx) = open_workspace(10, cx);
|
||||
|
||||
Reference in new issue
Block a user