mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
Alt+1..9 are vim's tab keys, and tty7 takes all nine for Go to Tab. Two things stood between the reporter and getting them back. **Nothing in the app could leave an action unbound.** Backspace on a Keybindings row that has recorded nothing *reset* the row — dropped the override so the action gets its shipped chord back. On a row nobody had overridden, which is every row the first time it is looked at, that is a no-op: pressing it over Alt+1 left Alt+1 sitting exactly where it was, which reads as the default restoring itself. `config.json` has spelled "no chord" as `[]` since #868, but no gesture wrote it. Backspace now writes that empty list. The row falls to `—` and grows the **Reset** button every overridden row has, which is the way back to the default. The capture hint names the key, and the docs say what it is for. **A keybinding line serde could not read failed the whole `Config`.** `keybindings` is a hand-edited map and was strict, so `"ActivateTab1": null` — or a number, or an object — quarantined `config.json` and started the app on built-in defaults. Every rebinding in the file then read as its shipped default, and the next settings write persisted those defaults over what the user had written. It now reads one entry at a time, like every other hand-edited nested key here: the lines that name a shortcut bind, a line that does not is logged and skipped. Tests, each failing on the unfixed code: - `ui::app::keybinding_gpui_tests::backspace_on_a_row_unbinds_the_action_rather_than_restoring_its_default` - `core::config::tests::a_keybinding_line_that_cannot_be_read_does_not_take_the_config_with_it` and `ui::keymap::gpui_tests::alt_digits_can_be_moved_off_the_tab_actions_for_good` pins the merge and a save/reload round trip: a list replaces the shipped Alt+1, `[]` leaves nothing, and neither comes back after a restart. Fixes #901 Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2622 lines
106 KiB
Rust
2622 lines
106 KiB
Rust
use gpui::{App, Global, KeyBinding, Keystroke, NoAction};
|
||
|
||
use crate::core::actions::*;
|
||
use crate::core::config::{Config, KeybindingOverride};
|
||
use crate::terminal::view::{
|
||
AlternatePaste, ClearScrollback, CopyText, FindInTerminal, FindNext, FindPrevious,
|
||
InsertNewline, InsertNewlineFallback, PasteText,
|
||
};
|
||
use crate::ui::i18n::{L10nKey, t, t_fmt};
|
||
use crate::ui::palette::CommandGroup;
|
||
use crate::ui::settings::humanize_action;
|
||
use crate::ui::theme::set_menus;
|
||
|
||
/// The bindings that were already in the keymap before tty7 put its own
|
||
/// there. gpui-component installs a table of its own from `gpui_component::
|
||
/// init` — the `Input` context's editing keys, the list and menu navigation,
|
||
/// the escape that closes a dialog — and it has no entry point to install
|
||
/// them a second time. A rebuild replaces the whole map, so it has to lay
|
||
/// these back down first, in the order they were added, or the map that comes
|
||
/// out has no backspace in any text field in the app (#548).
|
||
struct BaseBindings(Vec<KeyBinding>);
|
||
impl Global for BaseBindings {}
|
||
|
||
pub fn init(cx: &mut App) {
|
||
if cx.try_global::<BaseBindings>().is_none() {
|
||
let base: Vec<KeyBinding> = cx.key_bindings().borrow().bindings().cloned().collect();
|
||
cx.set_global(BaseBindings(base));
|
||
}
|
||
rebuild_keymap(cx);
|
||
cx.on_action(|_: &Quit, cx: &mut App| cx.quit());
|
||
// On the app rather than on a window, for the same reason `Quit` is: this
|
||
// is the one action in the table whose whole point is the state where no
|
||
// window is there to dispatch it. `show_tray_icon` is on by default, so
|
||
// closing the last window retires tty7 to the tray instead of quitting —
|
||
// process alive, nothing on screen — and a `NewWindow` that only exists on
|
||
// a window's render root is dead in exactly the state a New Window chord
|
||
// is for. `Tty7App`'s own listener still wins wherever there is a window:
|
||
// gpui runs the window's bubble phase first and returns before the global
|
||
// one, so the two never both fire.
|
||
cx.on_action(|_: &NewWindow, cx: &mut App| crate::ui::windows::open(cx, None));
|
||
set_menus(cx);
|
||
}
|
||
|
||
/// The fixed bindings every keymap carries, whatever the config says. These
|
||
/// are the terminal's own keys (Tab/BackTab as input), the font-size step
|
||
/// that has no settings row, and the modal-context guards the dispatch path
|
||
/// needs — none of them come from `effective_bindings`, so a rebuild that
|
||
/// only replayed the config would drop them.
|
||
fn fixed_bindings() -> Vec<KeyBinding> {
|
||
let mut bindings = vec![
|
||
KeyBinding::new("secondary-+", IncreaseFontSize, None),
|
||
KeyBinding::new("tab", SendTab, Some("Terminal")),
|
||
KeyBinding::new("shift-tab", SendBackTab, Some("Terminal")),
|
||
];
|
||
// The switcher's footer tells you Tab is the way across to the tab column
|
||
// once a query is in the box. gpui-component's Root binds `tab` to its
|
||
// focus walker, actions are dispatched before key listeners, and the panel
|
||
// was therefore losing every Tab to the ellipsis button in its own header —
|
||
// ring and all. A binding on the panel's own context is deeper in the
|
||
// dispatch path, so it wins.
|
||
bindings.push(KeyBinding::new("tab", SwitcherAcross, Some("Switcher")));
|
||
bindings.push(KeyBinding::new(
|
||
"shift-tab",
|
||
SwitcherAcrossBack,
|
||
Some("Switcher"),
|
||
));
|
||
// The palette has nowhere for Tab to go — the arrows walk the list and
|
||
// Enter runs it — but Root's focus walker still had somewhere to send it:
|
||
// out of the modal, onto whichever chrome tile is behind it, ring and all.
|
||
bindings.push(KeyBinding::new("tab", NoAction {}, Some("Palette")));
|
||
bindings.push(KeyBinding::new("shift-tab", NoAction {}, Some("Palette")));
|
||
bindings
|
||
}
|
||
|
||
/// Replaces the keymap with the full set: the bindings tty7 inherited, then
|
||
/// the ones the live config asks for, then the fixed ones.
|
||
///
|
||
/// gpui's `Keymap::add_bindings` only ever pushes — it never dedups and
|
||
/// nothing retires a binding — so rebinding by *appending* (the old `rebind`)
|
||
/// leaked one full table per call, and every keystroke walks the whole map.
|
||
/// Clearing first makes a rebind O(map) instead of O(map × calls), and makes
|
||
/// it safe to drive from the config watcher: a hand edit that only reorders
|
||
/// or re-comments the file reloads to an identical triple and never reaches
|
||
/// here, while a real change rebuilds once (#548). The order is the order
|
||
/// `init` bound them in, and gpui reads the map back to front, so tty7's
|
||
/// bindings still win over the inherited ones.
|
||
fn rebuild_keymap(cx: &mut App) {
|
||
let effective = effective_bindings(cx);
|
||
let mut bindings: Vec<KeyBinding> = cx
|
||
.try_global::<BaseBindings>()
|
||
.map(|base| base.0.clone())
|
||
.unwrap_or_default();
|
||
bindings.extend(action_bindings(&effective));
|
||
bindings.extend(fixed_bindings());
|
||
cx.clear_key_bindings();
|
||
cx.bind_keys(bindings);
|
||
}
|
||
|
||
pub fn rebind(cx: &mut App) {
|
||
rebuild_keymap(cx);
|
||
set_menus(cx);
|
||
}
|
||
|
||
/// The three config fields `effective_bindings` reads, as one comparable
|
||
/// value. The config watcher reloads on every write — including the app's
|
||
/// own `save()`, which fires on a sidebar drag — so it compares the triple
|
||
/// before and after and only rebinds when one of these actually moved;
|
||
/// otherwise each save would rebuild the keymap for nothing (#548).
|
||
pub(crate) fn keybinding_config(cx: &App) -> (Vec<(String, KeybindingOverride)>, String, String) {
|
||
let cfg = cx.global::<Config>();
|
||
let mut overrides: Vec<(String, KeybindingOverride)> = cfg
|
||
.keybindings
|
||
.iter()
|
||
.map(|(a, k)| (a.clone(), k.clone()))
|
||
.collect();
|
||
// A HashMap's order is not stable across reloads, and a reordered map is
|
||
// not a changed binding set.
|
||
overrides.sort();
|
||
(overrides, cfg.keybinding_preset.clone(), cfg.prefix.clone())
|
||
}
|
||
|
||
const INSERT_NEWLINE_DEFAULT: &str = "shift-enter";
|
||
|
||
const INSERT_NEWLINE_ALT_DEFAULT: &str = "alt-enter";
|
||
|
||
const PASTE_ALT_DEFAULT: &str = "shift-insert";
|
||
|
||
fn paste_text_default() -> &'static str {
|
||
per_platform("", "ctrl-shift-v")
|
||
}
|
||
|
||
/// Windows and Linux paste with Ctrl+V everywhere else in the desktop, so the
|
||
/// terminal answers it too — but only off the alternate screen, where the key
|
||
/// is a control code a full-screen program is waiting for (#677). It is a
|
||
/// binding of its own rather than a second keystroke on `PasteText` so that it
|
||
/// can carry that narrower context, and so that a user who wants the Windows
|
||
/// Terminal behaviour back can say so: `"AlternatePaste": ""` hands Ctrl+V to
|
||
/// the shell at the prompt as well, and `"PasteText": "ctrl-v"` pastes with it
|
||
/// on every screen.
|
||
fn alternate_paste_default() -> &'static str {
|
||
per_platform("", "ctrl-v")
|
||
}
|
||
|
||
fn extra_defaults() -> Vec<(&'static str, &'static str, &'static str)> {
|
||
vec![
|
||
(
|
||
"InsertNewline",
|
||
INSERT_NEWLINE_DEFAULT,
|
||
INSERT_NEWLINE_ALT_DEFAULT,
|
||
),
|
||
("PasteText", paste_text_default(), PASTE_ALT_DEFAULT),
|
||
]
|
||
}
|
||
|
||
fn extra_keystrokes(effective: &[(String, String)]) -> Vec<(&'static str, &'static str)> {
|
||
extra_defaults()
|
||
.into_iter()
|
||
.filter(|(action, primary, _)| {
|
||
!primary.is_empty() && effective.iter().any(|(a, k)| a == action && k == primary)
|
||
})
|
||
.map(|(action, _, extra)| (action, extra))
|
||
.collect()
|
||
}
|
||
|
||
fn is_default_insert_newline_binding(action: &str, key: &str) -> bool {
|
||
action == "InsertNewline" && key == INSERT_NEWLINE_DEFAULT
|
||
}
|
||
|
||
pub(crate) fn extra_bindings(cx: &App) -> Vec<(String, String)> {
|
||
extra_keystrokes(&effective_bindings(cx))
|
||
.into_iter()
|
||
.map(|(a, k)| (a.to_string(), k.to_string()))
|
||
.collect()
|
||
}
|
||
|
||
/// Installs `key` for `action`, and alongside it the chord the platform will
|
||
/// actually deliver when the two are spelled differently — `secondary-shift-]`
|
||
/// is pressed as `secondary-}`, and only the second ever reaches the
|
||
/// dispatcher. Both go in rather than one replacing the other, so a spec that
|
||
/// was already live stays live whatever the backend does with Shift (#750).
|
||
///
|
||
/// Answers whether the action was known at all, which is the caller's cue to
|
||
/// warn about a name it cannot bind.
|
||
fn push_binding(bindings: &mut Vec<KeyBinding>, action: &str, key: &str) -> bool {
|
||
let Some(binding) = make_binding(action, key) else {
|
||
return false;
|
||
};
|
||
bindings.push(binding);
|
||
if let Some(folded) = folded_spec(key)
|
||
&& let Some(alias) = make_binding(action, &folded)
|
||
{
|
||
bindings.push(alias);
|
||
}
|
||
true
|
||
}
|
||
|
||
fn action_bindings(effective: &[(String, String)]) -> Vec<KeyBinding> {
|
||
let mut bindings = Vec::new();
|
||
for (action, key) in extra_keystrokes(effective) {
|
||
push_binding(&mut bindings, action, key);
|
||
}
|
||
for (action, key) in effective {
|
||
if key.is_empty() {
|
||
continue;
|
||
}
|
||
if !keystroke_is_valid(key) {
|
||
log::warn!("ignoring keybinding for '{action}': invalid keystroke '{key}'");
|
||
continue;
|
||
}
|
||
// Said once, and the binding still installs: a chord the user asked
|
||
// for by name is the user's to spend, the way the tmux preset spends
|
||
// Ctrl+B. The invariant this guards is that no *default* spends one
|
||
// without saying so — `no_default_binding_sits_on_a_terminal_control_code`
|
||
// is the half of it that fails a build. A single chord only, since a
|
||
// prefix like `ctrl-b n` is that choice made deliberately.
|
||
// Over both spellings that reach the keymap, because the fold is what
|
||
// decides which of them the shell actually loses. `steals_a_control_code`
|
||
// only ever recognises a bare Ctrl, so it waves `ctrl-shift-2` through —
|
||
// and then the fold installs `ctrl-@` beside it, which is NUL. The chord
|
||
// was dead before it was folded and stole nothing; now that it is live
|
||
// the warning has to follow it (#750). At most one of the two can trip:
|
||
// a written chord that folds carries Shift, and one that carries Shift
|
||
// is never a control code.
|
||
let folded = folded_spec(key);
|
||
for chord in [Some(key.as_str()), folded.as_deref()]
|
||
.into_iter()
|
||
.flatten()
|
||
{
|
||
if !chord.contains(' ')
|
||
&& steals_a_control_code(chord)
|
||
&& !control_code_binding_allowed(action, chord)
|
||
{
|
||
log::warn!(
|
||
"keybinding '{chord}' for '{action}' takes a control code away from the shell"
|
||
);
|
||
}
|
||
}
|
||
if !push_binding(&mut bindings, action, key) {
|
||
log::warn!("ignoring keybinding: unknown action '{action}'");
|
||
} else if is_default_insert_newline_binding(action, key) {
|
||
bindings.push(KeyBinding::new(
|
||
INSERT_NEWLINE_DEFAULT,
|
||
InsertNewlineFallback,
|
||
Some("Terminal"),
|
||
));
|
||
}
|
||
}
|
||
bindings
|
||
}
|
||
|
||
/// Whether a chord is one the terminal owes the PTY as a control code.
|
||
///
|
||
/// Ctrl and nothing else, over the keys that carry a C0 byte: the alphabet,
|
||
/// `@ [ \ ] ^ _ / ?`, the digits 2..8 and Space — `ctrl_c0` in
|
||
/// `terminal::input` is the table this mirrors. A binding sitting on one of
|
||
/// these does not merely shadow the shell, it deletes a byte the program on
|
||
/// the far end is waiting for — Ctrl+D is EOF, Ctrl+W deletes a word, Ctrl+^
|
||
/// is vim's alternate file.
|
||
///
|
||
/// The backtick is in the set without being in that table — it is held over
|
||
/// from when this rule lived inside the defaults test. It errs the safe way:
|
||
/// a chord that encodes nothing gets a warning it did not strictly earn,
|
||
/// which is cheaper than a default quietly eating one that does.
|
||
fn steals_a_control_code(chord: &str) -> bool {
|
||
let Ok(ks) = Keystroke::parse(chord) else {
|
||
return false;
|
||
};
|
||
let m = &ks.modifiers;
|
||
if !m.control || m.alt || m.shift || m.platform || m.function {
|
||
return false;
|
||
}
|
||
ks.key == "space"
|
||
|| ks.key.len() == 1
|
||
&& ks.key.chars().next().is_some_and(|c| {
|
||
c.is_ascii_alphabetic() || "[]\\`^_/?@".contains(c) || ('2'..='8').contains(&c)
|
||
})
|
||
}
|
||
|
||
/// The bindings allowed to sit on a control code anyway.
|
||
///
|
||
/// `EditorSave` stays on Ctrl+S because its handler in `app.rs` calls
|
||
/// `cx.propagate()` whenever the editor does not have focus, so the keystroke
|
||
/// reaches the terminal as XOFF instead of dying at the window. Ctrl+V is the
|
||
/// paste chord every Windows and Linux desktop trains its users on; tty7
|
||
/// answers it the way Windows Terminal does, and keeps it off the alternate
|
||
/// screen (see `alternate_paste_default`), so it is allowed under any action —
|
||
/// including a `PasteText` a user deliberately moves onto it (#677).
|
||
///
|
||
/// Anything else added here needs a fall-through of its own; a binding that
|
||
/// simply swallows the byte does not belong on this list.
|
||
fn control_code_binding_allowed(action: &str, chord: &str) -> bool {
|
||
action == "EditorSave" || (cfg!(not(target_os = "macos")) && chord == "ctrl-v")
|
||
}
|
||
|
||
fn per_platform(mac: &'static str, other: &'static str) -> &'static str {
|
||
if cfg!(target_os = "macos") {
|
||
mac
|
||
} else {
|
||
other
|
||
}
|
||
}
|
||
|
||
pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
|
||
vec![
|
||
("NewTab", per_platform("secondary-t", "secondary-shift-t")),
|
||
("NewWorkspace", "secondary-shift-n"),
|
||
// Cmd+N is what "New Window" means on macOS, and nothing else here
|
||
// claims it. Off macOS both chords the convention offers are gone:
|
||
// Ctrl+N is a C0 byte the shell is owed, which
|
||
// `no_default_binding_sits_on_a_terminal_control_code` fails a build
|
||
// over, and Ctrl+Shift+N — where that same test says window actions
|
||
// belong — has been `NewWorkspace` for far longer than this action has
|
||
// existed. Minting an unguessable third chord would be worse than
|
||
// shipping unbound: the palette and the Keybindings page both carry
|
||
// this, so a key is one line of config away.
|
||
("NewWindow", per_platform("secondary-n", "")),
|
||
("CloseWindow", ""),
|
||
(
|
||
"CloseActiveTab",
|
||
per_platform("secondary-w", "secondary-shift-w"),
|
||
),
|
||
("RenameTab", ""),
|
||
("NewWorktreeTab", ""),
|
||
("CloseOtherTabs", ""),
|
||
("CloseTabsToTheRight", ""),
|
||
("CopyWorkingDirectory", ""),
|
||
("MarkTabUnread", ""),
|
||
("ForkAgentSession", ""),
|
||
("ForkAgentSessionRight", ""),
|
||
("ForkAgentSessionLeft", ""),
|
||
("ForkAgentSessionDown", ""),
|
||
("ForkAgentSessionUp", ""),
|
||
("CopyAgentSessionId", ""),
|
||
("StopWorkspace", ""),
|
||
("DeleteWorkspace", ""),
|
||
("RenameWorkspace", ""),
|
||
("ToggleSwitcher", "secondary-shift-o"),
|
||
(
|
||
"SplitRight",
|
||
per_platform("secondary-d", "secondary-shift-d"),
|
||
),
|
||
(
|
||
"SplitDown",
|
||
per_platform("secondary-shift-d", "secondary-alt-shift-d"),
|
||
),
|
||
(
|
||
"FocusNextPane",
|
||
per_platform("secondary-]", "secondary-shift-]"),
|
||
),
|
||
(
|
||
"FocusPrevPane",
|
||
per_platform("secondary-[", "secondary-shift-["),
|
||
),
|
||
(
|
||
"FocusPaneLeft",
|
||
per_platform("secondary-alt-left", "alt-left"),
|
||
),
|
||
(
|
||
"FocusPaneRight",
|
||
per_platform("secondary-alt-right", "alt-right"),
|
||
),
|
||
("FocusPaneUp", per_platform("secondary-alt-up", "alt-up")),
|
||
(
|
||
"FocusPaneDown",
|
||
per_platform("secondary-alt-down", "alt-down"),
|
||
),
|
||
("ResizePaneLeft", ""),
|
||
("ResizePaneRight", ""),
|
||
("ResizePaneUp", ""),
|
||
("ResizePaneDown", ""),
|
||
("SwapPaneNext", ""),
|
||
("SwapPanePrev", ""),
|
||
("NextTab", "ctrl-tab"),
|
||
("PrevTab", "ctrl-shift-tab"),
|
||
("ActivateTab1", per_platform("secondary-1", "alt-1")),
|
||
("ActivateTab2", per_platform("secondary-2", "alt-2")),
|
||
("ActivateTab3", per_platform("secondary-3", "alt-3")),
|
||
("ActivateTab4", per_platform("secondary-4", "alt-4")),
|
||
("ActivateTab5", per_platform("secondary-5", "alt-5")),
|
||
("ActivateTab6", per_platform("secondary-6", "alt-6")),
|
||
("ActivateTab7", per_platform("secondary-7", "alt-7")),
|
||
("ActivateTab8", per_platform("secondary-8", "alt-8")),
|
||
("ActivateTab9", per_platform("secondary-9", "alt-9")),
|
||
("SelectWorkspace1", ""),
|
||
("SelectWorkspace2", ""),
|
||
("SelectWorkspace3", ""),
|
||
("SelectWorkspace4", ""),
|
||
("SelectWorkspace5", ""),
|
||
("SelectWorkspace6", ""),
|
||
("SelectWorkspace7", ""),
|
||
("SelectWorkspace8", ""),
|
||
("SelectWorkspace9", ""),
|
||
("IncreaseFontSize", "secondary-="),
|
||
("DecreaseFontSize", "secondary--"),
|
||
("ResetFontSize", "secondary-0"),
|
||
(
|
||
"TogglePalette",
|
||
per_platform("secondary-p", "secondary-shift-p"),
|
||
),
|
||
(
|
||
"ReopenClosedTab",
|
||
per_platform("secondary-shift-t", "alt-shift-t"),
|
||
),
|
||
("ToggleMaximizePane", "secondary-shift-enter"),
|
||
// The one default that sits on a bare function key, and it stays
|
||
// there: F11 is the fullscreen chord Windows Terminal, GNOME Terminal
|
||
// and konsole all train their users on, and no shell binds it — the
|
||
// keys PSReadLine actually wants are F3, F7 and F8. gpui matches this
|
||
// binding before the pane's key handler runs, so the terminal never
|
||
// sees a bare F11; Shift/Ctrl/Alt+F11 are not bound and still reach
|
||
// the PTY as `\E[23;<mods>~`, and the whole chord is one line of
|
||
// config away from being retired. See
|
||
// `f11_is_the_only_default_on_a_bare_function_key`.
|
||
("ToggleFullscreen", per_platform("secondary-enter", "f11")),
|
||
("ToggleTabSidebar", ""),
|
||
(
|
||
"ToggleLeftPanel",
|
||
per_platform("secondary-b", "secondary-shift-b"),
|
||
),
|
||
(
|
||
"ToggleRightPanel",
|
||
per_platform("secondary-j", "secondary-shift-j"),
|
||
),
|
||
(
|
||
"FindInTerminal",
|
||
if cfg!(target_os = "macos") {
|
||
"secondary-f"
|
||
} else {
|
||
"ctrl-shift-f"
|
||
},
|
||
),
|
||
(
|
||
"FindNext",
|
||
if cfg!(target_os = "macos") {
|
||
"secondary-g"
|
||
} else {
|
||
"f3"
|
||
},
|
||
),
|
||
(
|
||
"FindPrevious",
|
||
if cfg!(target_os = "macos") {
|
||
"secondary-shift-g"
|
||
} else {
|
||
"shift-f3"
|
||
},
|
||
),
|
||
(
|
||
"ClearScrollback",
|
||
per_platform("secondary-k", "secondary-shift-k"),
|
||
),
|
||
("InsertNewline", INSERT_NEWLINE_DEFAULT),
|
||
("CopyText", per_platform("", "ctrl-shift-c")),
|
||
("PasteText", paste_text_default()),
|
||
("AlternatePaste", alternate_paste_default()),
|
||
("OpenSettings", "secondary-,"),
|
||
(
|
||
"ShowKeyboardShortcuts",
|
||
if cfg!(target_os = "macos") {
|
||
"secondary-/"
|
||
} else {
|
||
""
|
||
},
|
||
),
|
||
("About", ""),
|
||
("CheckForUpdates", ""),
|
||
("OpenDocumentation", ""),
|
||
("OpenDiscord", ""),
|
||
("ReportIssue", ""),
|
||
(
|
||
"HideApp",
|
||
if cfg!(target_os = "macos") {
|
||
"secondary-h"
|
||
} else {
|
||
""
|
||
},
|
||
),
|
||
(
|
||
"HideOthers",
|
||
if cfg!(target_os = "macos") {
|
||
"secondary-alt-h"
|
||
} else {
|
||
""
|
||
},
|
||
),
|
||
("ShowAll", ""),
|
||
(
|
||
"MinimizeWindow",
|
||
if cfg!(target_os = "macos") {
|
||
"secondary-m"
|
||
} else {
|
||
""
|
||
},
|
||
),
|
||
("ZoomWindow", ""),
|
||
// `secondary-enter` is `ToggleFullscreen` on macOS. That is not a
|
||
// clash: `ScmCommit` binds inside the `ScmCommit` key context, so it
|
||
// only wins while the caret sits in the commit box.
|
||
("ScmCommit", "secondary-enter"),
|
||
("ScmCommitAmend", ""),
|
||
("ScmStageAll", ""),
|
||
("ScmUnstageAll", ""),
|
||
("ScmDiscardAll", ""),
|
||
("ScmRefresh", ""),
|
||
("ScmSync", ""),
|
||
("ScmPush", ""),
|
||
("ScmPull", ""),
|
||
("ScmFetch", ""),
|
||
("ScmCheckoutBranch", ""),
|
||
("ScmCreateBranch", ""),
|
||
("ScmToggleGraph", ""),
|
||
("ToggleDiffViewMode", ""),
|
||
("ToggleSftp", ""),
|
||
("ShowSshForwards", ""),
|
||
("ToggleCodePanel", "secondary-shift-e"),
|
||
// Deliberately unbound. Docking is the default and Esc already gets the
|
||
// terminal back, so a default chord here would only be one more thing
|
||
// competing for a two-key combination nobody asked for.
|
||
("ToggleDocumentFill", ""),
|
||
("DocumentWidthThird", ""),
|
||
("DocumentWidthHalf", ""),
|
||
("DocumentWidthTwoThirds", ""),
|
||
// Implemented, dispatchable, and until now unbindable: `set_binding`
|
||
// only fills slots that exist here, so `"ShowRightPanelInfo": "ctrl-1"`
|
||
// in config.json was dropped without a word, and the Keybindings page —
|
||
// which reads this list — never showed them at all.
|
||
("ShowRightPanelInfo", ""),
|
||
("ShowRightPanelChanges", ""),
|
||
("ShowRightPanelFiles", ""),
|
||
("EditorSave", "secondary-s"),
|
||
("OpenSshProfiles", ""),
|
||
("RestartSshSession", "secondary-shift-r"),
|
||
("Quit", per_platform("secondary-q", "secondary-shift-q")),
|
||
]
|
||
}
|
||
|
||
/// Where an action sits on the Keybindings page, and what it is called there.
|
||
///
|
||
/// The page used to render `humanize_action`'s CamelCase split — English in a
|
||
/// three-locale app, and a fourth vocabulary on top of the menu bar, the
|
||
/// palette and the docs ("Toggle Maximize Pane" for what everything else calls
|
||
/// Zoom Pane). This routes both the name and the section through the strings
|
||
/// the rest of the app already uses.
|
||
pub(crate) fn action_entry(action: &str) -> (CommandGroup, String) {
|
||
authored_entry(action).unwrap_or_else(|| {
|
||
// An action with no entry still renders, under Application and with its
|
||
// name split on capitals — a new action shows up on the page rather
|
||
// than disappearing from it. `every_action_has_an_authored_name` is
|
||
// what keeps that from being how the page looks.
|
||
(CommandGroup::Application, humanize_action(action))
|
||
})
|
||
}
|
||
|
||
fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
|
||
// The numbered families are one row each in nine copies; a templated label
|
||
// beats nine hand-written strings per locale.
|
||
if let Some(n) = action.strip_prefix("ActivateTab") {
|
||
return Some((
|
||
CommandGroup::TabsPanes,
|
||
t_fmt(L10nKey::KeybindGoToTab, &[("n", n)]),
|
||
));
|
||
}
|
||
if let Some(n) = action.strip_prefix("SelectWorkspace") {
|
||
return Some((
|
||
CommandGroup::Workspaces,
|
||
t_fmt(L10nKey::KeybindGoToWorkspace, &[("n", n)]),
|
||
));
|
||
}
|
||
Some(match action {
|
||
"NewTab" => (CommandGroup::TabsPanes, t(L10nKey::CmdNewTab).to_string()),
|
||
"CloseActiveTab" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdClosePaneTab).to_string(),
|
||
),
|
||
"RenameTab" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdRenameTab).to_string(),
|
||
),
|
||
"NewWorktreeTab" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdNewWorktreeTab).to_string(),
|
||
),
|
||
"CloseOtherTabs" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdCloseOtherTabs).to_string(),
|
||
),
|
||
"CloseTabsToTheRight" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdCloseTabsToTheRight).to_string(),
|
||
),
|
||
"CopyWorkingDirectory" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdCopyWorkingDirectory).to_string(),
|
||
),
|
||
"MarkTabUnread" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdMarkTabAsUnread).to_string(),
|
||
),
|
||
"ReopenClosedTab" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdReopenClosedTab).to_string(),
|
||
),
|
||
"SplitRight" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdSplitRight).to_string(),
|
||
),
|
||
"SplitDown" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdSplitDown).to_string(),
|
||
),
|
||
"ToggleMaximizePane" => (CommandGroup::TabsPanes, t(L10nKey::CmdZoomPane).to_string()),
|
||
"FocusNextPane" => (CommandGroup::TabsPanes, t(L10nKey::CmdNextPane).to_string()),
|
||
"FocusPrevPane" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdPreviousPane).to_string(),
|
||
),
|
||
"FocusPaneLeft" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdFocusPaneLeft).to_string(),
|
||
),
|
||
"FocusPaneRight" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdFocusPaneRight).to_string(),
|
||
),
|
||
"FocusPaneUp" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdFocusPaneUp).to_string(),
|
||
),
|
||
"FocusPaneDown" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdFocusPaneDown).to_string(),
|
||
),
|
||
"ResizePaneLeft" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdResizePaneLeft).to_string(),
|
||
),
|
||
"ResizePaneRight" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdResizePaneRight).to_string(),
|
||
),
|
||
"ResizePaneUp" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdResizePaneUp).to_string(),
|
||
),
|
||
"ResizePaneDown" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdResizePaneDown).to_string(),
|
||
),
|
||
"SwapPaneNext" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdSwapPaneNext).to_string(),
|
||
),
|
||
"SwapPanePrev" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdSwapPanePrevious).to_string(),
|
||
),
|
||
"NextTab" => (CommandGroup::TabsPanes, t(L10nKey::CmdNextTab).to_string()),
|
||
"PrevTab" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdPreviousTab).to_string(),
|
||
),
|
||
"NewWorkspace" => (
|
||
CommandGroup::Workspaces,
|
||
t(L10nKey::CmdNewWorkspace).to_string(),
|
||
),
|
||
"RenameWorkspace" => (
|
||
CommandGroup::Workspaces,
|
||
t(L10nKey::CmdRenameWorkspace).to_string(),
|
||
),
|
||
"StopWorkspace" => (
|
||
CommandGroup::Workspaces,
|
||
t(L10nKey::CmdStopWorkspace).to_string(),
|
||
),
|
||
"DeleteWorkspace" => (
|
||
CommandGroup::Workspaces,
|
||
t(L10nKey::CmdDeleteWorkspace).to_string(),
|
||
),
|
||
"ToggleSwitcher" => (
|
||
CommandGroup::Workspaces,
|
||
t(L10nKey::CmdSwitchWorkspace).to_string(),
|
||
),
|
||
"IncreaseFontSize" => (
|
||
CommandGroup::View,
|
||
t(L10nKey::AppMenuIncreaseFontSize).to_string(),
|
||
),
|
||
"DecreaseFontSize" => (
|
||
CommandGroup::View,
|
||
t(L10nKey::AppMenuDecreaseFontSize).to_string(),
|
||
),
|
||
"ResetFontSize" => (CommandGroup::View, t(L10nKey::CmdResetFontSize).to_string()),
|
||
"ToggleFullscreen" => (
|
||
CommandGroup::View,
|
||
t(L10nKey::CmdEnterFullScreen).to_string(),
|
||
),
|
||
"ToggleTabSidebar" => (
|
||
CommandGroup::View,
|
||
t(L10nKey::AppMenuTabBarPosition).to_string(),
|
||
),
|
||
"ToggleLeftPanel" => (
|
||
CommandGroup::View,
|
||
t(L10nKey::AppMenuLeftSidebar).to_string(),
|
||
),
|
||
"ToggleRightPanel" => (
|
||
CommandGroup::View,
|
||
t(L10nKey::AppMenuRightPanel).to_string(),
|
||
),
|
||
"ToggleCodePanel" => (CommandGroup::View, t(L10nKey::AppMenuCodePanel).to_string()),
|
||
"ToggleDocumentFill" => (
|
||
CommandGroup::View,
|
||
t(L10nKey::CmdToggleDocumentFill).to_string(),
|
||
),
|
||
"DocumentWidthThird" => (
|
||
CommandGroup::View,
|
||
t(L10nKey::CmdDocumentWidthThird).to_string(),
|
||
),
|
||
"DocumentWidthHalf" => (
|
||
CommandGroup::View,
|
||
t(L10nKey::CmdDocumentWidthHalf).to_string(),
|
||
),
|
||
"DocumentWidthTwoThirds" => (
|
||
CommandGroup::View,
|
||
t(L10nKey::CmdDocumentWidthTwoThirds).to_string(),
|
||
),
|
||
"ShowRightPanelInfo" => (
|
||
CommandGroup::View,
|
||
t(L10nKey::CmdRightPanelInfo).to_string(),
|
||
),
|
||
"ShowRightPanelChanges" => (
|
||
CommandGroup::View,
|
||
t(L10nKey::CmdRightPanelChanges).to_string(),
|
||
),
|
||
"ShowRightPanelFiles" => (
|
||
CommandGroup::View,
|
||
t(L10nKey::CmdRightPanelFiles).to_string(),
|
||
),
|
||
"FindInTerminal" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::CmdFindInTerminal).to_string(),
|
||
),
|
||
"FindNext" => (CommandGroup::Terminal, t(L10nKey::CmdFindNext).to_string()),
|
||
"FindPrevious" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::CmdFindPrevious).to_string(),
|
||
),
|
||
"ClearScrollback" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::CmdClearScrollback).to_string(),
|
||
),
|
||
"CopyText" => (CommandGroup::Terminal, t(L10nKey::CmdCopy).to_string()),
|
||
"PasteText" => (CommandGroup::Terminal, t(L10nKey::CmdPaste).to_string()),
|
||
"AlternatePaste" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::CmdAlternatePaste).to_string(),
|
||
),
|
||
"InsertNewline" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::KeybindInsertNewline).to_string(),
|
||
),
|
||
"EditorSave" => (CommandGroup::Terminal, t(L10nKey::Save).to_string()),
|
||
"OpenSshProfiles" => (
|
||
CommandGroup::Ssh,
|
||
t(L10nKey::CmdSshManageProfiles).to_string(),
|
||
),
|
||
"RestartSshSession" => (CommandGroup::Ssh, t(L10nKey::CmdSshReconnect).to_string()),
|
||
"ToggleSftp" => (CommandGroup::Ssh, t(L10nKey::CmdSshRemoteFiles).to_string()),
|
||
"ShowSshForwards" => (
|
||
CommandGroup::Ssh,
|
||
t(L10nKey::CmdSshPortForwarding).to_string(),
|
||
),
|
||
"ForkAgentSession" => (CommandGroup::Agents, t(L10nKey::CmdForkSession).to_string()),
|
||
"ForkAgentSessionRight" => (
|
||
CommandGroup::Agents,
|
||
t(L10nKey::KeybindForkSessionRight).to_string(),
|
||
),
|
||
"ForkAgentSessionLeft" => (
|
||
CommandGroup::Agents,
|
||
t(L10nKey::KeybindForkSessionLeft).to_string(),
|
||
),
|
||
"ForkAgentSessionDown" => (
|
||
CommandGroup::Agents,
|
||
t(L10nKey::KeybindForkSessionDown).to_string(),
|
||
),
|
||
"ForkAgentSessionUp" => (
|
||
CommandGroup::Agents,
|
||
t(L10nKey::KeybindForkSessionUp).to_string(),
|
||
),
|
||
"CopyAgentSessionId" => (
|
||
CommandGroup::Agents,
|
||
t(L10nKey::CmdCopySessionId).to_string(),
|
||
),
|
||
"TogglePalette" => (
|
||
CommandGroup::Application,
|
||
t(L10nKey::AppMenuCommandPalette).to_string(),
|
||
),
|
||
"NewWindow" => (
|
||
CommandGroup::Application,
|
||
t(L10nKey::CmdNewWindow).to_string(),
|
||
),
|
||
"CloseWindow" => (
|
||
CommandGroup::Application,
|
||
t(L10nKey::CmdCloseWindow).to_string(),
|
||
),
|
||
"OpenSettings" => (
|
||
CommandGroup::Application,
|
||
t(L10nKey::CmdSettings).to_string(),
|
||
),
|
||
"ShowKeyboardShortcuts" => (
|
||
CommandGroup::Application,
|
||
t(L10nKey::CmdKeyboardShortcuts).to_string(),
|
||
),
|
||
"About" => (
|
||
CommandGroup::Application,
|
||
t(L10nKey::CmdAboutTty7).to_string(),
|
||
),
|
||
"CheckForUpdates" => (
|
||
CommandGroup::Application,
|
||
t(L10nKey::CmdCheckForUpdates).to_string(),
|
||
),
|
||
"OpenDocumentation" => (
|
||
CommandGroup::Application,
|
||
t(L10nKey::CmdDocumentation).to_string(),
|
||
),
|
||
"OpenDiscord" => (
|
||
CommandGroup::Application,
|
||
t(L10nKey::CmdJoinDiscord).to_string(),
|
||
),
|
||
"ReportIssue" => (
|
||
CommandGroup::Application,
|
||
t(L10nKey::CmdReportIssue).to_string(),
|
||
),
|
||
"HideApp" => (
|
||
CommandGroup::Application,
|
||
t(L10nKey::AppMenuHideApp).to_string(),
|
||
),
|
||
"HideOthers" => (
|
||
CommandGroup::Application,
|
||
t(L10nKey::AppMenuHideOthers).to_string(),
|
||
),
|
||
"ShowAll" => (
|
||
CommandGroup::Application,
|
||
t(L10nKey::AppMenuShowAll).to_string(),
|
||
),
|
||
"MinimizeWindow" => (
|
||
CommandGroup::Application,
|
||
t(L10nKey::AppMenuMinimize).to_string(),
|
||
),
|
||
"ZoomWindow" => (
|
||
CommandGroup::Application,
|
||
t(L10nKey::AppMenuZoom).to_string(),
|
||
),
|
||
"Quit" => (
|
||
CommandGroup::Application,
|
||
t(L10nKey::CmdQuitTty7).to_string(),
|
||
),
|
||
// The source control verbs wear their palette names, so the
|
||
// Keybindings page and the palette agree on what a chord does.
|
||
"ScmCommit" => (CommandGroup::Git, t(L10nKey::CmdGitCommit).to_string()),
|
||
"ScmCommitAmend" => (
|
||
CommandGroup::Git,
|
||
t(L10nKey::ScmAmendLastCommit).to_string(),
|
||
),
|
||
"ScmStageAll" => (CommandGroup::Git, t(L10nKey::CmdGitStageAll).to_string()),
|
||
"ScmUnstageAll" => (CommandGroup::Git, t(L10nKey::CmdGitUnstageAll).to_string()),
|
||
"ScmDiscardAll" => (CommandGroup::Git, t(L10nKey::CmdGitDiscardAll).to_string()),
|
||
"ScmRefresh" => (CommandGroup::Git, t(L10nKey::ScmRefresh).to_string()),
|
||
"ScmSync" => (CommandGroup::Git, t(L10nKey::CmdGitSync).to_string()),
|
||
"ScmPush" => (CommandGroup::Git, t(L10nKey::CmdGitPush).to_string()),
|
||
"ScmPull" => (CommandGroup::Git, t(L10nKey::CmdGitPull).to_string()),
|
||
"ScmFetch" => (CommandGroup::Git, t(L10nKey::CmdGitFetch).to_string()),
|
||
"ScmCheckoutBranch" => (CommandGroup::Git, t(L10nKey::CmdGitCheckoutTo).to_string()),
|
||
"ScmCreateBranch" => (
|
||
CommandGroup::Git,
|
||
t(L10nKey::CmdGitCreateBranch).to_string(),
|
||
),
|
||
"ScmToggleGraph" => (CommandGroup::Git, t(L10nKey::CmdGitToggleGraph).to_string()),
|
||
"ToggleDiffViewMode" => (
|
||
CommandGroup::View,
|
||
t(L10nKey::CmdToggleDiffViewMode).to_string(),
|
||
),
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// One action and the chords it answers to, split by where they came from:
|
||
/// the ones it ships with — its default, or the preset's in its place — and
|
||
/// the ones `config.json` names.
|
||
///
|
||
/// Kept apart because they install apart. gpui resolves two bindings on one
|
||
/// chord to the one added last, and a chord the user asked for by name has to
|
||
/// be that one, wherever its action sits in the table.
|
||
struct ActionChords {
|
||
action: String,
|
||
shipped: Vec<String>,
|
||
configured: Vec<String>,
|
||
}
|
||
|
||
fn resolve_chords(
|
||
preset: &str,
|
||
prefix: &str,
|
||
overrides: &std::collections::HashMap<String, KeybindingOverride>,
|
||
) -> Vec<ActionChords> {
|
||
let mut resolved: Vec<ActionChords> = default_bindings()
|
||
.into_iter()
|
||
.map(|(action, key)| ActionChords {
|
||
action: action.to_string(),
|
||
shipped: [key]
|
||
.into_iter()
|
||
.filter(|k| !k.is_empty())
|
||
.map(str::to_string)
|
||
.collect(),
|
||
configured: Vec::new(),
|
||
})
|
||
.collect();
|
||
// A preset is a scheme rather than an addition: its chord takes the
|
||
// default's place.
|
||
for (action, key) in preset_bindings(preset, prefix) {
|
||
if let Some(slot) = resolved.iter_mut().find(|s| s.action == action) {
|
||
slot.shipped = vec![key];
|
||
}
|
||
}
|
||
for (action, value) in overrides {
|
||
let Some(slot) = resolved.iter_mut().find(|s| s.action == *action) else {
|
||
// A hand-edited config.json with a typo used to vanish into this
|
||
// branch. The Keybindings page lists every name that works.
|
||
log::warn!("keybinding for unknown action {action:?} ignored");
|
||
continue;
|
||
};
|
||
match value {
|
||
// `""` has always been how a config retires a chord — the docs
|
||
// spell `"AlternatePaste": ""`, and Settings wrote it for an action
|
||
// whose chord was taken — so it goes on unbinding the action.
|
||
KeybindingOverride::Add(key) if key.is_empty() => {
|
||
slot.shipped.clear();
|
||
}
|
||
// A chord beside the ones the action has, not in place of them
|
||
// (#868): `"NextTab": "cmd-shift-]"` is a second way to switch tabs,
|
||
// and Ctrl+Tab going dead because of it was the bug.
|
||
KeybindingOverride::Add(key) => {
|
||
if !slot.shipped.iter().any(|k| same_chord(k, key)) {
|
||
slot.configured.push(key.clone());
|
||
}
|
||
}
|
||
KeybindingOverride::Exact(keys) => {
|
||
slot.shipped.clear();
|
||
for key in keys {
|
||
if !key.is_empty() && !slot.configured.iter().any(|k| same_chord(k, key)) {
|
||
slot.configured.push(key.clone());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
resolved
|
||
}
|
||
|
||
/// Every chord as an `(action, chord)` pair, in the order the keymap is built
|
||
/// from: all the shipped chords, then all the configured ones, so that a
|
||
/// configured chord landing on another action's default is the binding that
|
||
/// wins it. An action with no chord at all has no pair.
|
||
fn flatten_chords(resolved: &[ActionChords]) -> Vec<(String, String)> {
|
||
let pairs = |pick: fn(&ActionChords) -> &Vec<String>| {
|
||
resolved
|
||
.iter()
|
||
.flat_map(move |s| pick(s).iter().map(|k| (s.action.clone(), k.clone())))
|
||
};
|
||
pairs(|s| &s.shipped)
|
||
.chain(pairs(|s| &s.configured))
|
||
.collect()
|
||
}
|
||
|
||
pub(crate) fn effective_bindings(cx: &App) -> Vec<(String, String)> {
|
||
let cfg = cx.global::<Config>();
|
||
flatten_chords(&resolve_chords(
|
||
&cfg.keybinding_preset,
|
||
&cfg.prefix,
|
||
&cfg.keybindings,
|
||
))
|
||
}
|
||
|
||
/// Every action in table order with all of its chords, shipped first — an
|
||
/// empty list for an action that has none. What a page listing the actions
|
||
/// reads; the keymap reads [`effective_bindings`].
|
||
pub(crate) fn effective_chords(cx: &App) -> Vec<(String, Vec<String>)> {
|
||
let cfg = cx.global::<Config>();
|
||
resolve_chords(&cfg.keybinding_preset, &cfg.prefix, &cfg.keybindings)
|
||
.into_iter()
|
||
.map(|mut s| {
|
||
s.shipped.append(&mut s.configured);
|
||
(s.action, s.shipped)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn preset_bindings(preset: &str, prefix: &str) -> Vec<(String, String)> {
|
||
match preset {
|
||
"tmux" => tmux_preset(prefix),
|
||
_ => Vec::new(),
|
||
}
|
||
}
|
||
|
||
fn tmux_preset(prefix: &str) -> Vec<(String, String)> {
|
||
let p = |key: &str| format!("{prefix} {key}");
|
||
[
|
||
("NewTab", p("c")),
|
||
("CloseActiveTab", p("x")),
|
||
("SplitRight", p("%")),
|
||
("SplitDown", p("\"")),
|
||
("FocusPaneLeft", p("left")),
|
||
("FocusPaneRight", p("right")),
|
||
("FocusPaneUp", p("up")),
|
||
("FocusPaneDown", p("down")),
|
||
("ResizePaneLeft", p("ctrl-left")),
|
||
("ResizePaneRight", p("ctrl-right")),
|
||
("ResizePaneUp", p("ctrl-up")),
|
||
("ResizePaneDown", p("ctrl-down")),
|
||
("SwapPanePrev", p("{")),
|
||
("SwapPaneNext", p("}")),
|
||
("ToggleMaximizePane", p("z")),
|
||
("FocusNextPane", p("o")),
|
||
("FocusPrevPane", p(";")),
|
||
("NextTab", p("n")),
|
||
("PrevTab", p("p")),
|
||
("ActivateTab1", p("1")),
|
||
("ActivateTab2", p("2")),
|
||
("ActivateTab3", p("3")),
|
||
("ActivateTab4", p("4")),
|
||
("ActivateTab5", p("5")),
|
||
("ActivateTab6", p("6")),
|
||
("ActivateTab7", p("7")),
|
||
("ActivateTab8", p("8")),
|
||
("ActivateTab9", p("9")),
|
||
]
|
||
.into_iter()
|
||
.map(|(a, k)| (a.to_string(), k))
|
||
.collect()
|
||
}
|
||
|
||
pub(crate) fn effective_key(action: &str, cx: &App) -> Option<String> {
|
||
effective_bindings(cx)
|
||
.into_iter()
|
||
.find(|(a, _)| a == action)
|
||
.map(|(_, k)| k)
|
||
.filter(|k| !k.is_empty())
|
||
}
|
||
|
||
pub(crate) fn spec_from_keystroke(ks: &Keystroke) -> Option<String> {
|
||
if matches!(
|
||
ks.key.as_str(),
|
||
"shift" | "control" | "alt" | "platform" | "function" | "cmd" | "ctrl"
|
||
) {
|
||
return None;
|
||
}
|
||
let m = &ks.modifiers;
|
||
let mut parts: Vec<&str> = Vec::new();
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
if m.platform {
|
||
parts.push("secondary");
|
||
}
|
||
if m.control {
|
||
parts.push("ctrl");
|
||
}
|
||
}
|
||
#[cfg(not(target_os = "macos"))]
|
||
{
|
||
if m.control {
|
||
parts.push("secondary");
|
||
}
|
||
if m.platform {
|
||
parts.push("cmd");
|
||
}
|
||
}
|
||
if m.alt {
|
||
parts.push("alt");
|
||
}
|
||
if m.shift {
|
||
parts.push("shift");
|
||
}
|
||
if m.function {
|
||
parts.push("fn");
|
||
}
|
||
let mut spec = String::new();
|
||
for part in parts {
|
||
spec.push_str(part);
|
||
spec.push('-');
|
||
}
|
||
spec.push_str(&ks.key);
|
||
Some(spec)
|
||
}
|
||
|
||
/// The glyph a US keyboard prints on the shifted half of every key that is not
|
||
/// a letter — `shift-]` types `}`, `shift-2` types `@`.
|
||
const SHIFTED_GLYPHS: [(char, char); 21] = [
|
||
('`', '~'),
|
||
('1', '!'),
|
||
('2', '@'),
|
||
('3', '#'),
|
||
('4', '$'),
|
||
('5', '%'),
|
||
('6', '^'),
|
||
('7', '&'),
|
||
('8', '*'),
|
||
('9', '('),
|
||
('0', ')'),
|
||
('-', '_'),
|
||
('=', '+'),
|
||
('[', '{'),
|
||
(']', '}'),
|
||
('\\', '|'),
|
||
(';', ':'),
|
||
('\'', '"'),
|
||
(',', '<'),
|
||
('.', '>'),
|
||
('/', '?'),
|
||
];
|
||
|
||
/// Rewrites one chord into the shape the platform actually delivers, or
|
||
/// returns `None` when it is already that shape.
|
||
///
|
||
/// Every gpui backend carries Shift as a modifier for letters only. Over the
|
||
/// digits and the punctuation it spends the modifier on the character instead
|
||
/// and clears the flag: that is the `chars_with_shift` branch on macOS,
|
||
/// `need_to_convert_to_shifted_key` in the Windows mapper, and Linux's "we
|
||
/// only include the shift for upper-case letters by convention". So the chord
|
||
/// written `secondary-shift-]` arrives as `secondary-}` and the two never
|
||
/// meet — the binding installs and is unreachable, which is why Ctrl+Shift+]
|
||
/// stopped cycling panes off macOS and why hand-editing a config to that
|
||
/// spelling changed nothing (#750).
|
||
///
|
||
/// Folding the spec the same way on the lookup side closes the round trip.
|
||
/// The recorder already writes `secondary-}`, because that is what
|
||
/// `spec_from_keystroke` was handed; a default or a config written the other
|
||
/// way now installs the very same binding, so both spellings keep working and
|
||
/// nobody's config comes undone. `secondary-shift-}`, which people also reach
|
||
/// for, folds onto it too — the flag is simply redundant there.
|
||
///
|
||
/// The table is US-layout, and a chord it does not know is left alone rather
|
||
/// than guessed at. It cannot be layout-aware from here: `?` is Shift+ß on a
|
||
/// German keyboard and Shift+, on a French one, and the only mapper that knows
|
||
/// which is gpui's, reached through `KeyBinding::load` rather than the
|
||
/// `KeyBinding::new` that `make_binding` uses — and it exists on Windows only,
|
||
/// where `MacKeyboardMapper` does no shift folding at all. So off a US layout
|
||
/// the fold is a widening that may not land: `ctrl-shift-4` picks up `ctrl-$`,
|
||
/// which on AZERTY is a key of its own. Harmless, because the fold only ever
|
||
/// adds — the spec as written stays bound whatever the layout — but it is why
|
||
/// this is not the last word on #750.
|
||
///
|
||
/// The fold is *added* to a spec's bindings, never substituted for them,
|
||
/// because the convention is not universal. Windows leaves Shift intact on
|
||
/// the numpad's `/ * + -`: `need_to_convert_to_shifted_key` lists the OEM
|
||
/// keys and `VK_0..VK_9` but none of `VK_DIVIDE`/`VK_MULTIPLY`/`VK_ADD`/
|
||
/// `VK_SUBTRACT`, so `get_keystroke_key` falls through to `get_key_from_vkey`
|
||
/// and Ctrl+Shift+numpad-/ really does arrive as `ctrl-shift-/`. Substituting
|
||
/// `ctrl-?` for it would unbind a chord that works today — the same class of
|
||
/// bug this is fixing. macOS clears the flag there anyway (the numpad glyph
|
||
/// is the same shifted or not, so `chars_with_shift` takes the branch that
|
||
/// drops it) and Linux names those keys `divide`/`multiply`/`add`/`subtract`,
|
||
/// which are not one character and never reach this.
|
||
fn fold_shift_into_glyph(chord: &str) -> Option<String> {
|
||
let mut ks = Keystroke::parse(chord).ok()?;
|
||
if !ks.modifiers.shift {
|
||
return None;
|
||
}
|
||
let mut chars = ks.key.chars();
|
||
let (Some(key), None) = (chars.next(), chars.next()) else {
|
||
return None;
|
||
};
|
||
if let Some((_, shifted)) = SHIFTED_GLYPHS.iter().find(|(plain, _)| *plain == key) {
|
||
ks.key = shifted.to_string();
|
||
} else if !SHIFTED_GLYPHS.iter().any(|(_, shifted)| *shifted == key) {
|
||
return None;
|
||
}
|
||
ks.modifiers.shift = false;
|
||
spec_from_keystroke(&ks)
|
||
}
|
||
|
||
/// `spec` with every chord folded, or `None` when it is already the shape the
|
||
/// platform delivers and there is nothing to add.
|
||
pub(crate) fn folded_spec(spec: &str) -> Option<String> {
|
||
let mut folded = String::with_capacity(spec.len());
|
||
let mut changed = false;
|
||
for chord in spec.split_whitespace() {
|
||
if !folded.is_empty() {
|
||
folded.push(' ');
|
||
}
|
||
match fold_shift_into_glyph(chord) {
|
||
Some(chord) => {
|
||
folded.push_str(&chord);
|
||
changed = true;
|
||
}
|
||
None => folded.push_str(chord),
|
||
}
|
||
}
|
||
changed.then_some(folded)
|
||
}
|
||
|
||
/// Whether two specs claim the same keystroke — spelled the same way, or one
|
||
/// the fold of the other. `secondary-shift-]` and `secondary-}` are one chord
|
||
/// written twice, and a rebind that cannot see that leaves both installed on
|
||
/// it, where which one fires is arbitrary (#750).
|
||
pub(crate) fn same_chord(a: &str, b: &str) -> bool {
|
||
if a == b {
|
||
return true;
|
||
}
|
||
let (folded_a, folded_b) = (folded_spec(a), folded_spec(b));
|
||
folded_a.as_deref() == Some(b)
|
||
|| folded_b.as_deref() == Some(a)
|
||
|| (folded_a.is_some() && folded_a == folded_b)
|
||
}
|
||
|
||
pub(crate) fn key_chords(spec: &str) -> Vec<Vec<String>> {
|
||
spec.split_whitespace().map(key_tokens).collect()
|
||
}
|
||
|
||
/// How to write the "secondary" modifier for this platform — ⌘ on macOS, Ctrl
|
||
/// everywhere else. Anything spelling a shortcut out in the UI needs this
|
||
/// rather than a literal ⌘.
|
||
pub(crate) fn secondary_glyph() -> &'static str {
|
||
if cfg!(target_os = "macos") {
|
||
"⌘"
|
||
} else {
|
||
"Ctrl"
|
||
}
|
||
}
|
||
|
||
pub(crate) fn key_tokens(spec: &str) -> Vec<String> {
|
||
#[cfg(target_os = "macos")]
|
||
const MODS: [(&str, &str); 6] = [
|
||
("secondary", "⌘"),
|
||
("cmd", "⌘"),
|
||
("ctrl", "⌃"),
|
||
("alt", "⌥"),
|
||
("shift", "⇧"),
|
||
("fn", "fn"),
|
||
];
|
||
#[cfg(not(target_os = "macos"))]
|
||
const MODS: [(&str, &str); 6] = [
|
||
("secondary", "Ctrl"),
|
||
("cmd", "Win"),
|
||
("ctrl", "Ctrl"),
|
||
("alt", "Alt"),
|
||
("shift", "Shift"),
|
||
("fn", "Fn"),
|
||
];
|
||
let mut rest = spec;
|
||
let mut tokens = Vec::new();
|
||
'outer: loop {
|
||
for (name, glyph) in MODS {
|
||
let prefix = format!("{name}-");
|
||
if let Some(stripped) = rest.strip_prefix(&prefix) {
|
||
if !stripped.is_empty() {
|
||
tokens.push(glyph.to_string());
|
||
rest = stripped;
|
||
continue 'outer;
|
||
}
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
tokens.push(key_glyph(rest));
|
||
tokens
|
||
}
|
||
|
||
fn key_glyph(key: &str) -> String {
|
||
match key {
|
||
"enter" | "return" => "⏎".into(),
|
||
"tab" => "⇥".into(),
|
||
"space" => "Space".into(),
|
||
"escape" | "esc" => "⎋".into(),
|
||
"backspace" => "⌫".into(),
|
||
"up" => "↑".into(),
|
||
"down" => "↓".into(),
|
||
"left" => "←".into(),
|
||
"right" => "→".into(),
|
||
"-" => "−".into(),
|
||
other => other.to_uppercase(),
|
||
}
|
||
}
|
||
|
||
fn keystroke_is_valid(s: &str) -> bool {
|
||
let mut any = false;
|
||
for token in s.split_whitespace() {
|
||
any = true;
|
||
if gpui::Keystroke::parse(token).is_err() {
|
||
return false;
|
||
}
|
||
}
|
||
any
|
||
}
|
||
|
||
fn action_context(action: &str) -> Option<&'static str> {
|
||
match action {
|
||
"FindInTerminal" | "FindNext" | "FindPrevious" | "ClearScrollback" | "InsertNewline"
|
||
| "CopyText" | "PasteText" => Some("Terminal"),
|
||
// `alt_screen` is declared by the pane whenever a full-screen program
|
||
// owns the grid, so this binding is simply absent there and Ctrl+V
|
||
// carries on to the PTY as SYN (#677).
|
||
"AlternatePaste" => Some("Terminal && !alt_screen"),
|
||
"ScmCommit" | "ScmCommitAmend" => Some("ScmCommit"),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
|
||
Some(match action {
|
||
"NewTab" => KeyBinding::new(keystroke, NewTab, None),
|
||
"NewWorkspace" => KeyBinding::new(keystroke, NewWorkspace, None),
|
||
"StopWorkspace" => KeyBinding::new(keystroke, StopWorkspace, None),
|
||
"DeleteWorkspace" => KeyBinding::new(keystroke, DeleteWorkspace, None),
|
||
"RenameWorkspace" => KeyBinding::new(keystroke, RenameWorkspace, None),
|
||
"ToggleSwitcher" => KeyBinding::new(keystroke, ToggleSwitcher, None),
|
||
"NewWindow" => KeyBinding::new(keystroke, NewWindow, None),
|
||
"CloseWindow" => KeyBinding::new(keystroke, CloseWindow, None),
|
||
"CloseActiveTab" => KeyBinding::new(keystroke, CloseActiveTab, None),
|
||
"RenameTab" => KeyBinding::new(keystroke, RenameTab, None),
|
||
"NewWorktreeTab" => KeyBinding::new(keystroke, NewWorktreeTab, None),
|
||
"CloseOtherTabs" => KeyBinding::new(keystroke, CloseOtherTabs, None),
|
||
"CloseTabsToTheRight" => KeyBinding::new(keystroke, CloseTabsToTheRight, None),
|
||
"CopyWorkingDirectory" => KeyBinding::new(keystroke, CopyWorkingDirectory, None),
|
||
"MarkTabUnread" => KeyBinding::new(keystroke, MarkTabUnread, None),
|
||
"ForkAgentSession" => KeyBinding::new(keystroke, ForkAgentSession, None),
|
||
"ForkAgentSessionRight" => KeyBinding::new(keystroke, ForkAgentSessionRight, None),
|
||
"ForkAgentSessionLeft" => KeyBinding::new(keystroke, ForkAgentSessionLeft, None),
|
||
"ForkAgentSessionDown" => KeyBinding::new(keystroke, ForkAgentSessionDown, None),
|
||
"ForkAgentSessionUp" => KeyBinding::new(keystroke, ForkAgentSessionUp, None),
|
||
"CopyAgentSessionId" => KeyBinding::new(keystroke, CopyAgentSessionId, None),
|
||
"SplitRight" => KeyBinding::new(keystroke, SplitRight, None),
|
||
"SplitDown" => KeyBinding::new(keystroke, SplitDown, None),
|
||
"FocusNextPane" => KeyBinding::new(keystroke, FocusNextPane, None),
|
||
"FocusPrevPane" => KeyBinding::new(keystroke, FocusPrevPane, None),
|
||
"FocusPaneLeft" => KeyBinding::new(keystroke, FocusPaneLeft, None),
|
||
"FocusPaneRight" => KeyBinding::new(keystroke, FocusPaneRight, None),
|
||
"FocusPaneUp" => KeyBinding::new(keystroke, FocusPaneUp, None),
|
||
"FocusPaneDown" => KeyBinding::new(keystroke, FocusPaneDown, None),
|
||
"ResizePaneLeft" => KeyBinding::new(keystroke, ResizePaneLeft, None),
|
||
"ResizePaneRight" => KeyBinding::new(keystroke, ResizePaneRight, None),
|
||
"ResizePaneUp" => KeyBinding::new(keystroke, ResizePaneUp, None),
|
||
"ResizePaneDown" => KeyBinding::new(keystroke, ResizePaneDown, None),
|
||
"SwapPaneNext" => KeyBinding::new(keystroke, SwapPaneNext, None),
|
||
"SwapPanePrev" => KeyBinding::new(keystroke, SwapPanePrev, None),
|
||
"NextTab" => KeyBinding::new(keystroke, NextTab, None),
|
||
"PrevTab" => KeyBinding::new(keystroke, PrevTab, None),
|
||
"ActivateTab1" => KeyBinding::new(keystroke, ActivateTab1, None),
|
||
"ActivateTab2" => KeyBinding::new(keystroke, ActivateTab2, None),
|
||
"ActivateTab3" => KeyBinding::new(keystroke, ActivateTab3, None),
|
||
"ActivateTab4" => KeyBinding::new(keystroke, ActivateTab4, None),
|
||
"ActivateTab5" => KeyBinding::new(keystroke, ActivateTab5, None),
|
||
"ActivateTab6" => KeyBinding::new(keystroke, ActivateTab6, None),
|
||
"ActivateTab7" => KeyBinding::new(keystroke, ActivateTab7, None),
|
||
"ActivateTab8" => KeyBinding::new(keystroke, ActivateTab8, None),
|
||
"ActivateTab9" => KeyBinding::new(keystroke, ActivateTab9, None),
|
||
"SelectWorkspace1" => KeyBinding::new(keystroke, SelectWorkspace1, None),
|
||
"SelectWorkspace2" => KeyBinding::new(keystroke, SelectWorkspace2, None),
|
||
"SelectWorkspace3" => KeyBinding::new(keystroke, SelectWorkspace3, None),
|
||
"SelectWorkspace4" => KeyBinding::new(keystroke, SelectWorkspace4, None),
|
||
"SelectWorkspace5" => KeyBinding::new(keystroke, SelectWorkspace5, None),
|
||
"SelectWorkspace6" => KeyBinding::new(keystroke, SelectWorkspace6, None),
|
||
"SelectWorkspace7" => KeyBinding::new(keystroke, SelectWorkspace7, None),
|
||
"SelectWorkspace8" => KeyBinding::new(keystroke, SelectWorkspace8, None),
|
||
"SelectWorkspace9" => KeyBinding::new(keystroke, SelectWorkspace9, None),
|
||
"IncreaseFontSize" => KeyBinding::new(keystroke, IncreaseFontSize, None),
|
||
"DecreaseFontSize" => KeyBinding::new(keystroke, DecreaseFontSize, None),
|
||
"ResetFontSize" => KeyBinding::new(keystroke, ResetFontSize, None),
|
||
"TogglePalette" => KeyBinding::new(keystroke, TogglePalette, None),
|
||
"ReopenClosedTab" => KeyBinding::new(keystroke, ReopenClosedTab, None),
|
||
"ToggleMaximizePane" => KeyBinding::new(keystroke, ToggleMaximizePane, None),
|
||
"ToggleFullscreen" => KeyBinding::new(keystroke, ToggleFullscreen, None),
|
||
"ToggleTabSidebar" => KeyBinding::new(keystroke, ToggleTabSidebar, None),
|
||
"ToggleLeftPanel" => KeyBinding::new(keystroke, ToggleLeftPanel, None),
|
||
"ToggleRightPanel" => KeyBinding::new(keystroke, ToggleRightPanel, None),
|
||
"ShowRightPanelInfo" => KeyBinding::new(keystroke, ShowRightPanelInfo, None),
|
||
"ShowRightPanelChanges" => KeyBinding::new(keystroke, ShowRightPanelChanges, None),
|
||
"ShowRightPanelFiles" => KeyBinding::new(keystroke, ShowRightPanelFiles, None),
|
||
"ScmCommit" => KeyBinding::new(keystroke, ScmCommit, action_context(action)),
|
||
"ScmCommitAmend" => KeyBinding::new(keystroke, ScmCommitAmend, action_context(action)),
|
||
"ScmStageAll" => KeyBinding::new(keystroke, ScmStageAll, None),
|
||
"ScmUnstageAll" => KeyBinding::new(keystroke, ScmUnstageAll, None),
|
||
"ScmDiscardAll" => KeyBinding::new(keystroke, ScmDiscardAll, None),
|
||
"ScmRefresh" => KeyBinding::new(keystroke, ScmRefresh, None),
|
||
"ScmSync" => KeyBinding::new(keystroke, ScmSync, None),
|
||
"ScmPush" => KeyBinding::new(keystroke, ScmPush, None),
|
||
"ScmPull" => KeyBinding::new(keystroke, ScmPull, None),
|
||
"ScmFetch" => KeyBinding::new(keystroke, ScmFetch, None),
|
||
"ScmCheckoutBranch" => KeyBinding::new(keystroke, ScmCheckoutBranch, None),
|
||
"ScmCreateBranch" => KeyBinding::new(keystroke, ScmCreateBranch, None),
|
||
"ScmToggleGraph" => KeyBinding::new(keystroke, ScmToggleGraph, None),
|
||
"ToggleDiffViewMode" => KeyBinding::new(keystroke, ToggleDiffViewMode, None),
|
||
"FindInTerminal" => KeyBinding::new(keystroke, FindInTerminal, action_context(action)),
|
||
"FindNext" => KeyBinding::new(keystroke, FindNext, action_context(action)),
|
||
"FindPrevious" => KeyBinding::new(keystroke, FindPrevious, action_context(action)),
|
||
"ClearScrollback" => KeyBinding::new(keystroke, ClearScrollback, action_context(action)),
|
||
"InsertNewline" => KeyBinding::new(keystroke, InsertNewline, action_context(action)),
|
||
"CopyText" => KeyBinding::new(keystroke, CopyText, action_context(action)),
|
||
"PasteText" => KeyBinding::new(keystroke, PasteText, action_context(action)),
|
||
"AlternatePaste" => KeyBinding::new(keystroke, AlternatePaste, action_context(action)),
|
||
"OpenSettings" => KeyBinding::new(keystroke, OpenSettings, None),
|
||
"ShowKeyboardShortcuts" => KeyBinding::new(keystroke, ShowKeyboardShortcuts, None),
|
||
"About" => KeyBinding::new(keystroke, About, None),
|
||
"CheckForUpdates" => KeyBinding::new(keystroke, CheckForUpdates, None),
|
||
"OpenDocumentation" => KeyBinding::new(keystroke, OpenDocumentation, None),
|
||
"OpenDiscord" => KeyBinding::new(keystroke, OpenDiscord, None),
|
||
"ReportIssue" => KeyBinding::new(keystroke, ReportIssue, None),
|
||
"HideApp" => KeyBinding::new(keystroke, HideApp, None),
|
||
"HideOthers" => KeyBinding::new(keystroke, HideOthers, None),
|
||
"ShowAll" => KeyBinding::new(keystroke, ShowAll, None),
|
||
"MinimizeWindow" => KeyBinding::new(keystroke, MinimizeWindow, None),
|
||
"ZoomWindow" => KeyBinding::new(keystroke, ZoomWindow, None),
|
||
"ToggleSftp" => KeyBinding::new(keystroke, ToggleSftp, None),
|
||
"ShowSshForwards" => KeyBinding::new(keystroke, ShowSshForwards, None),
|
||
"ToggleCodePanel" => KeyBinding::new(keystroke, ToggleCodePanel, None),
|
||
"ToggleDocumentFill" => KeyBinding::new(keystroke, ToggleDocumentFill, None),
|
||
"DocumentWidthThird" => KeyBinding::new(keystroke, DocumentWidthThird, None),
|
||
"DocumentWidthHalf" => KeyBinding::new(keystroke, DocumentWidthHalf, None),
|
||
"DocumentWidthTwoThirds" => KeyBinding::new(keystroke, DocumentWidthTwoThirds, None),
|
||
"EditorSave" => KeyBinding::new(keystroke, EditorSave, None),
|
||
"OpenSshProfiles" => KeyBinding::new(keystroke, OpenSshProfiles, None),
|
||
"RestartSshSession" => KeyBinding::new(keystroke, RestartSshSession, None),
|
||
"Quit" => KeyBinding::new(keystroke, Quit, None),
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use gpui::Action as _;
|
||
|
||
/// The actions a keymap built from `action_bindings` dispatches for `keys`
|
||
/// typed in `context`, in precedence order — the same lookup gpui performs
|
||
/// on a real keypress.
|
||
///
|
||
/// Bindings are asserted through this rather than through a mirror of the
|
||
/// table, so a chord the app would not really install, or would install in
|
||
/// another context, cannot pass.
|
||
fn dispatched(effective: &[(String, String)], keys: &str, context: &str) -> Vec<&'static str> {
|
||
let input: Vec<Keystroke> = keys
|
||
.split(' ')
|
||
.map(|k| Keystroke::parse(k).expect("the typed keystroke parses"))
|
||
.collect();
|
||
dispatched_keystrokes(effective, &input, context)
|
||
}
|
||
|
||
/// The same lookup for input a spec cannot spell — the shifted-glyph shape
|
||
/// a backend hands over for `secondary-shift-]` has no written form that
|
||
/// `Keystroke::parse` turns back into it.
|
||
fn dispatched_keystrokes(
|
||
effective: &[(String, String)],
|
||
input: &[Keystroke],
|
||
context: &str,
|
||
) -> Vec<&'static str> {
|
||
let mut keymap = gpui::Keymap::default();
|
||
keymap.add_bindings(action_bindings(effective));
|
||
let context = [gpui::KeyContext::parse(context).expect("the context parses")];
|
||
keymap
|
||
.bindings_for_input(input, &context)
|
||
.0
|
||
.iter()
|
||
.map(|b| b.action().name())
|
||
.collect()
|
||
}
|
||
|
||
/// What every gpui backend delivers when the secondary modifier is held
|
||
/// over a key whose shifted half is `glyph`: the glyph, and no shift flag —
|
||
/// the modifier is already spent on the character.
|
||
fn delivered_with_secondary(glyph: &str) -> Keystroke {
|
||
Keystroke {
|
||
modifiers: gpui::Modifiers {
|
||
control: cfg!(not(target_os = "macos")),
|
||
platform: cfg!(target_os = "macos"),
|
||
shift: false,
|
||
alt: false,
|
||
function: false,
|
||
},
|
||
key: glyph.to_string(),
|
||
key_char: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn every_dispatchable_action_has_a_slot_to_bind_it_in() {
|
||
// `make_binding` is what turns an action name into a real binding, and
|
||
// `default_bindings` is the only list `set_binding` will write into. An
|
||
// action in the first but not the second cannot be bound at all — not
|
||
// from config.json, which drops it silently, and not from the
|
||
// Keybindings page, which reads the second.
|
||
let bindable: std::collections::HashSet<&str> =
|
||
default_bindings().into_iter().map(|(a, _)| a).collect();
|
||
for action in [
|
||
"ShowRightPanelInfo",
|
||
"ShowRightPanelChanges",
|
||
"ShowRightPanelFiles",
|
||
] {
|
||
assert!(bindable.contains(action), "{action} has nowhere to bind to");
|
||
assert!(
|
||
make_binding(action, "ctrl-1").is_some(),
|
||
"{action} has a slot but nothing to dispatch"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn every_action_has_an_authored_name() {
|
||
crate::ui::i18n::set_locale("en");
|
||
let missing: Vec<&str> = default_bindings()
|
||
.into_iter()
|
||
.map(|(a, _)| a)
|
||
.filter(|a| authored_entry(a).is_none())
|
||
.collect();
|
||
assert!(
|
||
missing.is_empty(),
|
||
"these actions would render on the Keybindings page as a CamelCase \
|
||
split of their internal name, in English, in a three-locale app: {missing:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_page_speaks_the_same_words_as_the_rest_of_the_app() {
|
||
crate::ui::i18n::set_locale("en");
|
||
// The four names the critique found for one action; the menu bar, the
|
||
// palette and the docs all say Zoom Pane.
|
||
assert_eq!(action_entry("ToggleMaximizePane").1, "Zoom Pane");
|
||
assert_eq!(action_entry("CloseActiveTab").1, "Close Pane / Tab");
|
||
assert_eq!(action_entry("NewWindow").1, "New Window");
|
||
assert_eq!(action_entry("CloseWindow").1, "Close Window");
|
||
assert_eq!(action_entry("ClearScrollback").1, "Clear Scrollback");
|
||
assert_eq!(action_entry("TogglePalette").1, "Command Palette…");
|
||
assert_eq!(action_entry("ToggleSwitcher").1, "Switch Workspace…");
|
||
// The numbered families are templated, not nine strings per locale.
|
||
assert_eq!(action_entry("ActivateTab3").1, "Go to Tab 3");
|
||
assert_eq!(action_entry("SelectWorkspace7").1, "Go to Workspace 7");
|
||
// And they land in the section they belong to.
|
||
assert_eq!(action_entry("ActivateTab3").0, CommandGroup::TabsPanes);
|
||
assert_eq!(action_entry("SelectWorkspace7").0, CommandGroup::Workspaces);
|
||
assert_eq!(action_entry("ToggleSftp").0, CommandGroup::Ssh);
|
||
assert_eq!(action_entry("ForkAgentSessionUp").0, CommandGroup::Agents);
|
||
}
|
||
|
||
#[test]
|
||
fn new_window_ships_a_chord_only_where_one_is_free() {
|
||
let mut effective: Vec<(String, String)> = default_bindings()
|
||
.into_iter()
|
||
.map(|(a, k)| (a.to_string(), k.to_string()))
|
||
.collect();
|
||
let default = effective
|
||
.iter()
|
||
.find(|(action, _)| action == "NewWindow")
|
||
.map(|(_, key)| key.clone())
|
||
.expect("NewWindow has to be listed here or it cannot be bound at all");
|
||
assert_eq!(
|
||
default,
|
||
if cfg!(target_os = "macos") {
|
||
"secondary-n"
|
||
} else {
|
||
""
|
||
},
|
||
"macOS gets Cmd+N; off macOS this ships unbound on purpose"
|
||
);
|
||
|
||
// Ask the keymap rather than the table. A default can look bound and
|
||
// dispatch nothing — gpui folds shift into the punctuation glyph, so
|
||
// `secondary-shift-]` reached no action at all off macOS (#750). An
|
||
// exact match is also the conflict check: any other action holding
|
||
// this chord would show up in the list.
|
||
if !default.is_empty() {
|
||
assert_eq!(
|
||
dispatched(&effective, &default, "Terminal"),
|
||
vec![NewWindow::name_for_type()],
|
||
"{default} must reach NewWindow, and nothing else may answer it"
|
||
);
|
||
}
|
||
|
||
// Unbound still has to mean bindable. `set_binding` only writes into
|
||
// slots this table already has, and `make_binding` is what turns the
|
||
// name back into a dispatchable binding; miss either and the action
|
||
// sits on the Keybindings page, takes a key, and does nothing — which
|
||
// is the whole complaint in #710, not just the missing default.
|
||
effective
|
||
.iter_mut()
|
||
.find(|(action, _)| action == "NewWindow")
|
||
.expect("found once already")
|
||
.1 = "ctrl-alt-shift-n".to_string();
|
||
assert_eq!(
|
||
dispatched(&effective, "ctrl-alt-shift-n", "Terminal"),
|
||
vec![NewWindow::name_for_type()],
|
||
"a chord the user assigns to NewWindow has to reach it"
|
||
);
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
const SECONDARY: &str = "⌘";
|
||
#[cfg(not(target_os = "macos"))]
|
||
const SECONDARY: &str = "Ctrl";
|
||
#[cfg(target_os = "macos")]
|
||
const SHIFT: &str = "⇧";
|
||
#[cfg(not(target_os = "macos"))]
|
||
const SHIFT: &str = "Shift";
|
||
#[cfg(target_os = "macos")]
|
||
const CTRL: &str = "⌃";
|
||
#[cfg(not(target_os = "macos"))]
|
||
const CTRL: &str = "Ctrl";
|
||
|
||
#[test]
|
||
fn key_tokens_maps_modifiers_to_glyphs() {
|
||
assert_eq!(key_tokens("secondary-t"), vec![SECONDARY, "T"]);
|
||
assert_eq!(key_tokens("secondary-shift-d"), vec![SECONDARY, SHIFT, "D"]);
|
||
assert_eq!(key_tokens("secondary-enter"), vec![SECONDARY, "⏎"]);
|
||
}
|
||
|
||
#[test]
|
||
fn key_tokens_keeps_the_minus_key_as_the_final_token() {
|
||
assert_eq!(key_tokens("secondary--"), vec![SECONDARY, "−"]);
|
||
assert_eq!(key_tokens("secondary-="), vec![SECONDARY, "="]);
|
||
assert_eq!(key_tokens("secondary-,"), vec![SECONDARY, ","]);
|
||
}
|
||
|
||
#[test]
|
||
fn key_chords_splits_a_sequence_into_keycap_groups() {
|
||
assert_eq!(
|
||
key_chords("ctrl-b n"),
|
||
vec![
|
||
vec![CTRL.to_string(), "B".to_string()],
|
||
vec!["N".to_string()]
|
||
]
|
||
);
|
||
assert_eq!(key_chords("secondary-t"), vec![vec![SECONDARY, "T"]]);
|
||
}
|
||
|
||
#[test]
|
||
fn every_default_action_has_a_binding_builder_or_is_unbound() {
|
||
for (action, key) in default_bindings() {
|
||
if !key.is_empty() {
|
||
assert!(
|
||
keystroke_is_valid(key),
|
||
"default keystroke for {action} is invalid: {key:?}"
|
||
);
|
||
assert!(
|
||
make_binding(action, key).is_some(),
|
||
"no make_binding arm for action {action}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn every_action_has_a_binding_arm() {
|
||
// The sibling test above only reaches actions that ship with a default
|
||
// keystroke, which leaves the unbound ones — the majority — free to be
|
||
// listed in `default_bindings` with no `make_binding` arm behind them.
|
||
// Nothing surfaces that: the action shows up in Settings, the user
|
||
// assigns a key, and the key silently does nothing.
|
||
for (action, _) in default_bindings() {
|
||
assert!(
|
||
make_binding(action, "ctrl-f12").is_some(),
|
||
"no make_binding arm for action {action}; \
|
||
anyone who binds a key to it in Settings gets nothing"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_commit_key_only_fires_inside_the_commit_box() {
|
||
// `secondary-enter` is `ToggleFullscreen` on macOS. The two coexist
|
||
// only because the commit binding is scoped; drop the context and
|
||
// committing steals full screen everywhere.
|
||
assert_eq!(action_context("ScmCommit"), Some("ScmCommit"));
|
||
assert_eq!(action_context("ScmCommitAmend"), Some("ScmCommit"));
|
||
assert_eq!(action_context("ToggleFullscreen"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn tmux_preset_keystrokes_all_parse_and_map_to_actions() {
|
||
for (action, key) in tmux_preset("ctrl-b") {
|
||
assert!(
|
||
keystroke_is_valid(&key),
|
||
"tmux preset keystroke for {action} does not parse: {key:?}"
|
||
);
|
||
assert!(
|
||
make_binding(&action, &key).is_some(),
|
||
"tmux preset action {action} has no make_binding arm"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn insert_newline_ships_both_default_chords() {
|
||
let effective: Vec<(String, String)> = default_bindings()
|
||
.into_iter()
|
||
.map(|(a, k)| (a.to_string(), k.to_string()))
|
||
.collect();
|
||
assert_eq!(
|
||
effective
|
||
.iter()
|
||
.find(|(a, _)| a == "InsertNewline")
|
||
.map(|(_, k)| k.as_str()),
|
||
Some("shift-enter")
|
||
);
|
||
assert!(extra_keystrokes(&effective).contains(&("InsertNewline", "alt-enter")));
|
||
for key in ["shift-enter", "alt-enter"] {
|
||
assert!(keystroke_is_valid(key), "{key} does not parse");
|
||
assert!(
|
||
dispatched(&effective, key, "Terminal").contains(&InsertNewline::name_for_type()),
|
||
"{key} does not reach InsertNewline in a terminal"
|
||
);
|
||
}
|
||
assert_eq!(key_tokens("shift-enter"), vec![SHIFT, "⏎"]);
|
||
}
|
||
|
||
#[test]
|
||
fn shift_enter_fallback_retires_with_an_insert_newline_rebind() {
|
||
assert!(is_default_insert_newline_binding(
|
||
"InsertNewline",
|
||
"shift-enter"
|
||
));
|
||
assert!(!is_default_insert_newline_binding(
|
||
"InsertNewline",
|
||
"ctrl-o"
|
||
));
|
||
assert!(!is_default_insert_newline_binding("InsertNewline", ""));
|
||
}
|
||
|
||
#[test]
|
||
fn shift_enter_prefix_binding_still_waits_for_its_second_key() {
|
||
let mut effective = default_bindings()
|
||
.into_iter()
|
||
.map(|(action, key)| (action.to_string(), key.to_string()))
|
||
.collect::<Vec<_>>();
|
||
effective
|
||
.iter_mut()
|
||
.find(|(action, _)| action == "OpenSettings")
|
||
.unwrap()
|
||
.1 = "shift-enter x".to_string();
|
||
|
||
let mut keymap = gpui::Keymap::default();
|
||
keymap.add_bindings(action_bindings(&effective));
|
||
let input = [gpui::Keystroke::parse("shift-enter").unwrap()];
|
||
let context = [gpui::KeyContext::parse("Terminal").unwrap()];
|
||
let (_, pending) = keymap.bindings_for_input(&input, &context);
|
||
|
||
assert!(pending, "the keymap must wait for the second key");
|
||
|
||
let input = [
|
||
gpui::Keystroke::parse("shift-enter").unwrap(),
|
||
gpui::Keystroke::parse("x").unwrap(),
|
||
];
|
||
let (matched, pending) = keymap.bindings_for_input(&input, &context);
|
||
assert!(!pending);
|
||
assert!(
|
||
matched
|
||
.first()
|
||
.is_some_and(|binding| binding.action().partial_eq(&OpenSettings))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn paste_ships_both_terminal_chords_off_macos_and_retires_together() {
|
||
let effective: Vec<(String, String)> = default_bindings()
|
||
.into_iter()
|
||
.map(|(a, k)| (a.to_string(), k.to_string()))
|
||
.collect();
|
||
if cfg!(target_os = "macos") {
|
||
assert!(
|
||
!extra_keystrokes(&effective)
|
||
.iter()
|
||
.any(|(a, _)| *a == "PasteText"),
|
||
"macOS pastes with Cmd+V; no extra chord should install there"
|
||
);
|
||
return;
|
||
}
|
||
assert_eq!(
|
||
effective
|
||
.iter()
|
||
.find(|(a, _)| a == "PasteText")
|
||
.map(|(_, k)| k.as_str()),
|
||
Some("ctrl-shift-v")
|
||
);
|
||
assert_eq!(
|
||
effective
|
||
.iter()
|
||
.find(|(a, _)| a == "CopyText")
|
||
.map(|(_, k)| k.as_str()),
|
||
Some("ctrl-shift-c")
|
||
);
|
||
assert!(extra_keystrokes(&effective).contains(&("PasteText", "shift-insert")));
|
||
for key in ["ctrl-shift-v", "shift-insert"] {
|
||
assert!(
|
||
dispatched(&effective, key, "Terminal").contains(&PasteText::name_for_type()),
|
||
"{key} must paste in a terminal"
|
||
);
|
||
assert!(
|
||
!dispatched(&effective, key, "Workspace").contains(&PasteText::name_for_type()),
|
||
"{key} is a terminal chord and must not paste outside one"
|
||
);
|
||
}
|
||
// Plain Ctrl+V is `AlternatePaste`, and only where no full-screen
|
||
// program is running: on the alternate screen the chord belongs to
|
||
// that program and reaches it as SYN.
|
||
assert!(
|
||
dispatched(&effective, "ctrl-v", "Terminal").contains(&AlternatePaste::name_for_type()),
|
||
"ctrl-v pastes at a prompt off macOS"
|
||
);
|
||
assert!(
|
||
dispatched(&effective, "ctrl-v", "Terminal alt_screen").is_empty(),
|
||
"a full-screen program owns ctrl-v"
|
||
);
|
||
assert!(
|
||
dispatched(&effective, "ctrl-shift-v", "Terminal alt_screen")
|
||
.contains(&PasteText::name_for_type()),
|
||
"Ctrl+Shift+V is the paste that works on every screen"
|
||
);
|
||
// The two ways out, both of which the control-code validator has to
|
||
// let through: retire the chord, or hand it the whole screen. Both are
|
||
// one line in a `config.json`, so both are asserted against the whole
|
||
// default table with that line applied — a bare one-entry table would
|
||
// pass either assertion without the escape hatch working at all.
|
||
let with = |action: &str, value: KeybindingOverride| {
|
||
let overrides = std::collections::HashMap::from([(action.to_string(), value)]);
|
||
flatten_chords(&resolve_chords("default", "ctrl-b", &overrides))
|
||
};
|
||
let retired = with("AlternatePaste", KeybindingOverride::Add(String::new()));
|
||
assert!(
|
||
dispatched(&retired, "ctrl-v", "Terminal").is_empty(),
|
||
"an emptied AlternatePaste gives Ctrl+V back to the shell"
|
||
);
|
||
let everywhere = with("PasteText", KeybindingOverride::Add("ctrl-v".to_string()));
|
||
for context in ["Terminal", "Terminal alt_screen"] {
|
||
assert!(
|
||
dispatched(&everywhere, "ctrl-v", context).contains(&PasteText::name_for_type()),
|
||
"a user may put Paste itself on Ctrl+V and have it on every screen"
|
||
);
|
||
}
|
||
let rebound = vec![("PasteText".to_string(), "ctrl-alt-v".to_string())];
|
||
assert!(
|
||
!extra_keystrokes(&rebound)
|
||
.iter()
|
||
.any(|(a, _)| *a == "PasteText"),
|
||
"moving Paste off its default must retire Shift+Insert with it"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn rebinding_insert_newline_retires_both_default_chords() {
|
||
let effective = vec![("InsertNewline".to_string(), "ctrl-o".to_string())];
|
||
assert!(extra_keystrokes(&effective).is_empty());
|
||
assert_eq!(
|
||
dispatched(&effective, "ctrl-o", "Terminal"),
|
||
vec![InsertNewline::name_for_type()],
|
||
"the chord the config asks for is the one the keymap dispatches"
|
||
);
|
||
for retired in ["shift-enter", "alt-enter"] {
|
||
assert!(
|
||
dispatched(&effective, retired, "Terminal").is_empty(),
|
||
"{retired} is off the action now and must dispatch nothing"
|
||
);
|
||
}
|
||
|
||
let unbound = vec![("InsertNewline".to_string(), String::new())];
|
||
assert!(extra_keystrokes(&unbound).is_empty());
|
||
assert!(action_bindings(&unbound).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn each_binding_lands_in_the_context_its_action_is_scoped_to() {
|
||
let effective = vec![
|
||
("InsertNewline".to_string(), "shift-enter".to_string()),
|
||
("NewTab".to_string(), "secondary-t".to_string()),
|
||
];
|
||
// The newline is a terminal chord, and its default ships the fallback
|
||
// beside it; both are scoped, so neither reaches the rest of the app.
|
||
let mut newline = dispatched(&effective, "shift-enter", "Terminal");
|
||
newline.sort_unstable();
|
||
assert_eq!(
|
||
newline,
|
||
vec![
|
||
InsertNewline::name_for_type(),
|
||
InsertNewlineFallback::name_for_type(),
|
||
]
|
||
);
|
||
assert!(dispatched(&effective, "shift-enter", "Workspace").is_empty());
|
||
assert_eq!(
|
||
dispatched(&effective, "alt-enter", "Terminal"),
|
||
vec![InsertNewline::name_for_type()],
|
||
"the extra chord follows its action's scope"
|
||
);
|
||
// A window action has no context: it has to work from a terminal too.
|
||
for context in ["Terminal", "Workspace"] {
|
||
assert_eq!(
|
||
dispatched(&effective, "secondary-t", context),
|
||
vec![NewTab::name_for_type()],
|
||
"NewTab must not be scoped to {context}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn action_context_matches_the_scope_make_binding_installs() {
|
||
let extra_actions = extra_keystrokes(
|
||
&default_bindings()
|
||
.into_iter()
|
||
.map(|(a, k)| (a.to_string(), k.to_string()))
|
||
.collect::<Vec<_>>(),
|
||
);
|
||
let actions = default_bindings()
|
||
.into_iter()
|
||
.map(|(a, _)| a)
|
||
.chain(extra_actions.into_iter().map(|(a, _)| a));
|
||
for action in actions {
|
||
let binding =
|
||
make_binding(action, "f13").unwrap_or_else(|| panic!("no arm for {action}"));
|
||
let installed = binding.predicate().map(|p| p.to_string());
|
||
assert_eq!(
|
||
installed.as_deref(),
|
||
action_context(action),
|
||
"{action} is installed in a different context than `action_context` reports"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn secondary_enter_chords_are_distinct_from_insert_newline() {
|
||
let defaults = default_bindings();
|
||
let key_of = |action: &str| {
|
||
defaults
|
||
.iter()
|
||
.find(|(a, _)| *a == action)
|
||
.map(|(_, k)| *k)
|
||
.unwrap()
|
||
};
|
||
assert_eq!(
|
||
key_of("ToggleFullscreen"),
|
||
per_platform("secondary-enter", "f11")
|
||
);
|
||
assert_eq!(key_of("ToggleMaximizePane"), "secondary-shift-enter");
|
||
for window_chord in ["secondary-enter", "secondary-shift-enter"] {
|
||
assert_ne!(window_chord, INSERT_NEWLINE_DEFAULT);
|
||
assert_ne!(window_chord, INSERT_NEWLINE_ALT_DEFAULT);
|
||
}
|
||
for chord in [INSERT_NEWLINE_DEFAULT, INSERT_NEWLINE_ALT_DEFAULT] {
|
||
assert_ne!(chord, "shift-alt-enter");
|
||
assert_ne!(chord, "alt-shift-enter");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn no_default_binding_sits_on_a_terminal_control_code() {
|
||
// The invariant is "no default may *swallow* a terminal control code".
|
||
// The exceptions are named and justified in
|
||
// `control_code_binding_allowed`; anything new needs a fall-through of
|
||
// its own to join them.
|
||
for (action, spec) in default_bindings() {
|
||
// Both spellings, because `push_binding` installs both. "Window
|
||
// actions belong on ctrl-shift-*" stops being an escape over the
|
||
// digits and the punctuation the moment the fold clears the Shift
|
||
// again: `ctrl-shift-2` goes into the keymap as `ctrl-@` (#750).
|
||
let folded = folded_spec(spec);
|
||
for spelling in [Some(spec), folded.as_deref()].into_iter().flatten() {
|
||
for chord in spelling.split_whitespace() {
|
||
Keystroke::parse(chord).expect("default chords parse");
|
||
assert!(
|
||
!steals_a_control_code(chord)
|
||
|| control_code_binding_allowed(action, chord),
|
||
"{action} is bound to {spec}, installed as {chord}, which the shell \
|
||
needs as a control code \
|
||
(Ctrl+[ is ESC, Ctrl+D is EOF, Ctrl+W deletes a word, \
|
||
Ctrl+2..8 are NUL/ESC/FS/GS/RS/US/DEL). \
|
||
Window actions belong on ctrl-shift-* off macOS — over a letter, \
|
||
where the platform keeps the Shift."
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// F1..F12 now encode (#834), so a default sitting on one takes it away
|
||
/// from the shell the same way a Ctrl binding takes a control code: gpui
|
||
/// matches bindings before the pane's key handler runs, so a bound
|
||
/// function key never reaches the PTY at all.
|
||
///
|
||
/// The three that do are named here rather than left to be discovered,
|
||
/// and each answers for the shell key it stands on:
|
||
///
|
||
/// * F11 keeps fullscreen outright. It is the chord Windows Terminal,
|
||
/// GNOME Terminal and konsole all use, and no shell binds it —
|
||
/// PSReadLine's F keys are F3, F7 and F8.
|
||
/// * F3 and Shift+F3 are Find Next / Previous, and they fall through:
|
||
/// with no find bar open the listener in `terminal::view` calls
|
||
/// `cx.propagate()`, so PSReadLine's CharacterSearch still gets the key.
|
||
///
|
||
/// A fourth needs a fall-through of its own to join them.
|
||
#[test]
|
||
fn f11_is_the_only_default_on_a_bare_function_key() {
|
||
let mut bound = Vec::new();
|
||
for (action, spec) in default_bindings() {
|
||
for chord in spec.split_whitespace() {
|
||
let ks = Keystroke::parse(chord).expect("default chords parse");
|
||
if crate::terminal::input::is_function_key(&ks.key) {
|
||
bound.push((action, chord));
|
||
}
|
||
}
|
||
}
|
||
let expected: &[(&str, &str)] = if cfg!(target_os = "macos") {
|
||
&[]
|
||
} else {
|
||
&[
|
||
("FindNext", "f3"),
|
||
("FindPrevious", "shift-f3"),
|
||
("ToggleFullscreen", "f11"),
|
||
]
|
||
};
|
||
bound.sort();
|
||
let mut expected = expected.to_vec();
|
||
expected.sort();
|
||
assert_eq!(
|
||
bound, expected,
|
||
"a default on a function key hides it from the shell; \
|
||
PSReadLine wants F3, F7 and F8, readline's `bind -x` any of them"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_control_code_rule_knows_what_the_shell_needs() {
|
||
// The keys with a C0 byte behind them, and the modifier shape that
|
||
// reaches it: Ctrl alone. This is the predicate the defaults are held
|
||
// to above and the one `action_bindings` warns on.
|
||
for chord in [
|
||
"ctrl-d",
|
||
"ctrl-c",
|
||
"ctrl-[",
|
||
"ctrl-2",
|
||
"ctrl-6",
|
||
"ctrl-8",
|
||
"ctrl-/",
|
||
"ctrl-space",
|
||
] {
|
||
assert!(steals_a_control_code(chord), "{chord} is a control code");
|
||
}
|
||
for chord in [
|
||
"ctrl-shift-v",
|
||
"ctrl-alt-v",
|
||
"secondary-shift-t",
|
||
"ctrl-1",
|
||
"ctrl-9",
|
||
"ctrl--",
|
||
"ctrl-f3",
|
||
"alt-enter",
|
||
] {
|
||
assert!(
|
||
!steals_a_control_code(chord),
|
||
"{chord} carries no control code"
|
||
);
|
||
}
|
||
// Ctrl+V is allowed to anyone off macOS — it is how the default paste
|
||
// reaches the chord, and how a user moves the full-screen paste onto
|
||
// it — while Ctrl+D stays refused whoever asks.
|
||
assert_eq!(
|
||
control_code_binding_allowed("PasteText", "ctrl-v"),
|
||
cfg!(not(target_os = "macos"))
|
||
);
|
||
assert!(!control_code_binding_allowed("PasteText", "ctrl-d"));
|
||
assert!(control_code_binding_allowed("EditorSave", "secondary-s"));
|
||
}
|
||
|
||
#[test]
|
||
fn every_default_chord_is_claimed_by_exactly_one_action() {
|
||
// Per context, not globally: gpui resolves a keystroke by walking the
|
||
// focus chain outwards, so a chord bound inside a narrow context and
|
||
// again with no context is not a clash — the narrow one wins while
|
||
// that element has focus and the global one applies everywhere else.
|
||
// `ScmCommit` and `ToggleFullscreen` both take secondary-enter on that
|
||
// basis. Two bindings sharing a chord *and* a context is still a bug,
|
||
// because then which one fires is arbitrary.
|
||
// `same_chord`, not string equality: `secondary-shift-]` and
|
||
// `secondary-}` are two spellings of one keystroke and both install it.
|
||
let mut seen: Vec<(&str, &str, Option<&'static str>)> = Vec::new();
|
||
for (action, spec) in default_bindings() {
|
||
if spec.is_empty() {
|
||
continue;
|
||
}
|
||
let context = action_context(action);
|
||
if let Some((other, _, _)) = seen
|
||
.iter()
|
||
.find(|(_, s, c)| same_chord(s, spec) && *c == context)
|
||
{
|
||
panic!("{action} and {other} both claim {spec} in context {context:?}");
|
||
}
|
||
seen.push((action, spec, context));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn spec_from_keystroke_round_trips_through_parse() {
|
||
for spec in [
|
||
"secondary-t",
|
||
"secondary-shift-t",
|
||
"secondary-alt-left",
|
||
"ctrl-shift-tab",
|
||
"secondary--",
|
||
] {
|
||
let ks = Keystroke::parse(spec).unwrap();
|
||
let round = spec_from_keystroke(&ks).expect("real key produces a spec");
|
||
let reparsed = Keystroke::parse(&round).unwrap();
|
||
assert_eq!(
|
||
(reparsed.modifiers, reparsed.key),
|
||
(ks.modifiers, ks.key),
|
||
"round trip diverged for {spec}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_shifted_punctuation_chord_dispatches_however_it_is_spelled() {
|
||
// Recording ⌘⇧] wrote `secondary-}` — correctly, since that is the
|
||
// keystroke it was handed — while the default table and every config
|
||
// in the wild spell the same chord `secondary-shift-]`. Only one of
|
||
// the two used to reach the dispatcher (#750); all three do now.
|
||
let typed = delivered_with_secondary("}");
|
||
let recorded = spec_from_keystroke(&typed).expect("the recorder spells the chord");
|
||
assert_eq!(recorded, "secondary-}");
|
||
for spec in [recorded.as_str(), "secondary-shift-]", "secondary-shift-}"] {
|
||
let effective = [("NextTab".to_string(), spec.to_string())];
|
||
assert_eq!(
|
||
dispatched_keystrokes(&effective, std::slice::from_ref(&typed), ""),
|
||
vec![NextTab::name_for_type()],
|
||
"{spec} never reaches the dispatcher"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_default_pane_cycle_chord_reaches_its_action() {
|
||
// `secondary-]` on macOS, `secondary-shift-]` everywhere else — and
|
||
// either way the backend reports the chord as the bare glyph. The
|
||
// second spelling installed a binding nothing could press.
|
||
let effective: Vec<(String, String)> = default_bindings()
|
||
.into_iter()
|
||
.map(|(a, k)| (a.to_string(), k.to_string()))
|
||
.collect();
|
||
let glyph = if cfg!(target_os = "macos") { "]" } else { "}" };
|
||
assert!(
|
||
dispatched_keystrokes(&effective, &[delivered_with_secondary(glyph)], "")
|
||
.contains(&FocusNextPane::name_for_type()),
|
||
"the default pane-cycle chord dispatches nothing"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn folding_leaves_every_other_spelling_alone() {
|
||
// Shift over a letter is a real modifier, and the named keys have no
|
||
// shifted half at all — folding either would break far more than it
|
||
// fixed.
|
||
for spec in [
|
||
"secondary-shift-t",
|
||
"shift-enter",
|
||
"shift-insert",
|
||
"shift-tab",
|
||
"ctrl-shift-up",
|
||
"secondary-t",
|
||
"secondary--",
|
||
"ctrl-b n",
|
||
] {
|
||
assert_eq!(folded_spec(spec), None, "{spec} was rewritten");
|
||
}
|
||
// A prefix chord folds per chord, not as a whole.
|
||
assert_eq!(folded_spec("ctrl-b shift-5").as_deref(), Some("ctrl-b %"));
|
||
}
|
||
|
||
#[test]
|
||
fn a_chord_the_platform_delivers_with_shift_intact_stays_bound() {
|
||
// Windows leaves Shift on the numpad's `/ * + -` — they are absent
|
||
// from gpui's `need_to_convert_to_shifted_key`, so Ctrl+Shift+numpad-/
|
||
// arrives as `ctrl-shift-/` and the recorder writes exactly that.
|
||
// Folding is an addition, never a substitution, so that spec has to
|
||
// survive the treatment `secondary-shift-]` gets (#750).
|
||
let numpad = Keystroke {
|
||
modifiers: gpui::Modifiers {
|
||
control: cfg!(not(target_os = "macos")),
|
||
platform: cfg!(target_os = "macos"),
|
||
shift: true,
|
||
alt: false,
|
||
function: false,
|
||
},
|
||
key: "/".to_string(),
|
||
key_char: None,
|
||
};
|
||
assert_eq!(
|
||
spec_from_keystroke(&numpad).as_deref(),
|
||
Some("secondary-shift-/")
|
||
);
|
||
let effective = [("NextTab".to_string(), "secondary-shift-/".to_string())];
|
||
assert_eq!(
|
||
dispatched_keystrokes(&effective, std::slice::from_ref(&numpad), ""),
|
||
vec![NextTab::name_for_type()],
|
||
"the numpad chord lost the binding written for it"
|
||
);
|
||
// And the main row's `?`, which the same spec also stands for, still
|
||
// reaches the action — the fold added it rather than taking over.
|
||
assert_eq!(
|
||
dispatched_keystrokes(&effective, &[delivered_with_secondary("?")], ""),
|
||
vec![NextTab::name_for_type()],
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn two_spellings_of_one_chord_are_recognised_as_one() {
|
||
// What `assign_keybinding` leans on to displace the action already
|
||
// holding a chord, whichever way either of them is written down.
|
||
assert!(same_chord("secondary-shift-]", "secondary-}"));
|
||
assert!(same_chord("secondary-}", "secondary-shift-]"));
|
||
assert!(same_chord("secondary-shift-}", "secondary-shift-]"));
|
||
assert!(same_chord("secondary-t", "secondary-t"));
|
||
assert!(!same_chord("secondary-shift-]", "secondary-shift-["));
|
||
assert!(!same_chord("secondary-}", "secondary-{"));
|
||
}
|
||
|
||
#[test]
|
||
fn the_control_code_guard_follows_the_folded_chord() {
|
||
// The half of the control-code rule that only warns. A chord carrying
|
||
// Shift reads as safe on its own — `steals_a_control_code` recognises a
|
||
// bare Ctrl and nothing else — and it *was* safe while nothing could
|
||
// press it. The fold makes it live, and Ctrl+Shift+2 is delivered as
|
||
// Ctrl+@, which is the NUL the program on the far end is waiting for.
|
||
// So the guard is asked about the spelling that goes into the keymap
|
||
// (#750); `action_bindings` runs exactly this pair.
|
||
for (written, folded) in [
|
||
("ctrl-shift-2", per_platform("ctrl-@", "secondary-@")),
|
||
("ctrl-shift-6", per_platform("ctrl-^", "secondary-^")),
|
||
("ctrl-shift--", per_platform("ctrl-_", "secondary-_")),
|
||
("ctrl-shift-/", per_platform("ctrl-?", "secondary-?")),
|
||
] {
|
||
assert!(
|
||
!steals_a_control_code(written),
|
||
"{written} reads as safe as written, which is why the fold has to be checked"
|
||
);
|
||
assert_eq!(folded_spec(written).as_deref(), Some(folded));
|
||
assert!(
|
||
steals_a_control_code(folded),
|
||
"{written} is installed as {folded} and takes a control code"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_key_the_us_table_does_not_know_is_left_alone() {
|
||
// `SHIFTED_GLYPHS` is a US keyboard, and every other layout prints
|
||
// something else on the shifted half: `?` is Shift+ß on a German
|
||
// keyboard and Shift+, on a French one. The fold cannot know that, so
|
||
// it declines rather than guesses — a key it has never seen keeps its
|
||
// Shift, and the binding written for it stays exactly as written. That
|
||
// is also what keeps the fold additive: it can widen a spec's reach but
|
||
// never move it onto a chord the user did not ask for.
|
||
for spec in [
|
||
"secondary-shift-ß",
|
||
"secondary-shift-é",
|
||
"secondary-shift-ä",
|
||
"secondary-shift-ç",
|
||
"secondary-shift-ñ",
|
||
] {
|
||
assert_eq!(folded_spec(spec), None, "{spec} was guessed at");
|
||
}
|
||
// And the recorded spelling, which is what a non-US layout actually
|
||
// produces, needs no fold to reach the dispatcher in the first place.
|
||
let effective = [("NextTab".to_string(), "secondary-?".to_string())];
|
||
assert_eq!(
|
||
dispatched_keystrokes(&effective, &[delivered_with_secondary("?")], ""),
|
||
vec![NextTab::name_for_type()],
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn spec_from_keystroke_ignores_a_lone_modifier() {
|
||
let ks = Keystroke::parse("secondary").unwrap();
|
||
assert_eq!(spec_from_keystroke(&ks), None);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod gpui_tests {
|
||
use super::*;
|
||
use crate::core::config::Config;
|
||
use gpui::TestAppContext;
|
||
|
||
#[gpui::test]
|
||
fn init_then_rebind_installs_the_merged_table(cx: &mut TestAppContext) {
|
||
cx.update(|cx| {
|
||
gpui_component::init(cx);
|
||
cx.set_global(Config::default());
|
||
init(cx);
|
||
|
||
{
|
||
let cfg = cx.global_mut::<Config>();
|
||
cfg.keybinding_preset = "tmux".to_string();
|
||
cfg.keybindings.insert(
|
||
"NewTab".to_string(),
|
||
KeybindingOverride::Add("secondary-shift-n".to_string()),
|
||
);
|
||
}
|
||
rebind(cx);
|
||
|
||
let eff = effective_bindings(cx);
|
||
let keys_of = |action: &str| {
|
||
eff.iter()
|
||
.filter(|(a, _)| a == action)
|
||
.map(|(_, k)| k.clone())
|
||
.collect::<Vec<_>>()
|
||
};
|
||
assert_eq!(keys_of("NewTab"), ["ctrl-b c", "secondary-shift-n"]);
|
||
assert_eq!(keys_of("SplitRight"), ["ctrl-b %"]);
|
||
assert_eq!(
|
||
keys_of("TogglePalette"),
|
||
[per_platform("secondary-p", "secondary-shift-p")]
|
||
);
|
||
|
||
cx.global_mut::<Config>().keybinding_preset = "default".to_string();
|
||
rebind(cx);
|
||
let eff = effective_bindings(cx);
|
||
assert_eq!(
|
||
eff.iter()
|
||
.find(|(a, _)| a == "SplitRight")
|
||
.map(|(_, k)| k.as_str()),
|
||
Some(per_platform("secondary-d", "secondary-shift-d"))
|
||
);
|
||
});
|
||
}
|
||
|
||
#[gpui::test]
|
||
fn the_keybinding_triple_only_moves_when_a_binding_does(cx: &mut TestAppContext) {
|
||
cx.update(|cx| {
|
||
gpui_component::init(cx);
|
||
cx.set_global(Config::default());
|
||
init(cx);
|
||
|
||
let before = keybinding_config(cx);
|
||
assert_eq!(
|
||
keybinding_config(cx),
|
||
before,
|
||
"reading the config twice changes nothing"
|
||
);
|
||
|
||
// An unrelated edit leaves the triple alone — this is the gate the
|
||
// watcher compares, and a sidebar drag must not rebind.
|
||
cx.global_mut::<Config>().dim_inactive_panes = false;
|
||
assert_eq!(keybinding_config(cx), before);
|
||
|
||
// A real binding edit moves it.
|
||
cx.global_mut::<Config>().keybindings.insert(
|
||
"RenameTab".to_string(),
|
||
KeybindingOverride::Add("ctrl-shift-r".to_string()),
|
||
);
|
||
assert_ne!(keybinding_config(cx), before);
|
||
|
||
// So does the preset, and the prefix.
|
||
let with_override = keybinding_config(cx);
|
||
cx.global_mut::<Config>().keybinding_preset = "tmux".to_string();
|
||
assert_ne!(keybinding_config(cx), with_override);
|
||
let with_preset = keybinding_config(cx);
|
||
cx.global_mut::<Config>().prefix = "ctrl-a".to_string();
|
||
assert_ne!(keybinding_config(cx), with_preset);
|
||
});
|
||
}
|
||
|
||
/// An app whose `config.json` reads `json`, keymap and all. Parsed from
|
||
/// text rather than built in Rust, because the file is the surface #868 is
|
||
/// about: what a hand-written line means is the thing under test.
|
||
fn running_on_json(cx: &mut gpui::App, json: &str) {
|
||
gpui_component::init(cx);
|
||
cx.set_global(Config(
|
||
serde_json::from_str(json).expect("the config parses"),
|
||
));
|
||
init(cx);
|
||
}
|
||
|
||
/// What the live keymap dispatches for `keys` typed in a terminal, best
|
||
/// match first — the first entry is the action a real keypress runs.
|
||
fn fired(cx: &gpui::App, keys: &str) -> Vec<&'static str> {
|
||
let input: Vec<Keystroke> = keys
|
||
.split(' ')
|
||
.map(|k| Keystroke::parse(k).expect("the typed keystroke parses"))
|
||
.collect();
|
||
let context = [gpui::KeyContext::parse("Terminal").expect("the context parses")];
|
||
cx.key_bindings()
|
||
.borrow()
|
||
.bindings_for_input(&input, &context)
|
||
.0
|
||
.iter()
|
||
.map(|b| b.action().name())
|
||
.collect()
|
||
}
|
||
|
||
#[gpui::test]
|
||
fn a_chord_added_in_config_keeps_the_default_one(cx: &mut TestAppContext) {
|
||
use gpui::Action as _;
|
||
cx.update(|cx| {
|
||
// The report, word for word: a second way to reach the tab switcher
|
||
// took the first one away (#868).
|
||
running_on_json(
|
||
cx,
|
||
r#"{"keybindings": {"NextTab": "ctrl-alt-]", "PrevTab": "ctrl-alt-["}}"#,
|
||
);
|
||
assert_eq!(
|
||
fired(cx, "ctrl-tab").first(),
|
||
Some(&NextTab::name_for_type()),
|
||
"the default chord survives a chord added beside it"
|
||
);
|
||
assert_eq!(
|
||
fired(cx, "ctrl-shift-tab").first(),
|
||
Some(&PrevTab::name_for_type())
|
||
);
|
||
assert_eq!(
|
||
fired(cx, "ctrl-alt-]").first(),
|
||
Some(&NextTab::name_for_type()),
|
||
"and the added chord works too"
|
||
);
|
||
assert_eq!(
|
||
fired(cx, "ctrl-alt-[").first(),
|
||
Some(&PrevTab::name_for_type())
|
||
);
|
||
// The palette hint and the menus still name the chord the app
|
||
// ships with.
|
||
assert_eq!(effective_key("NextTab", cx).as_deref(), Some("ctrl-tab"));
|
||
});
|
||
}
|
||
|
||
#[gpui::test]
|
||
fn an_empty_chord_still_unbinds_the_action(cx: &mut TestAppContext) {
|
||
cx.update(|cx| {
|
||
// What config files already say to retire a default — the docs spell
|
||
// `"AlternatePaste": ""` — and what Settings used to write for an
|
||
// action that lost its chord. It has to keep meaning "no key".
|
||
running_on_json(cx, r#"{"keybindings": {"NextTab": ""}}"#);
|
||
assert!(fired(cx, "ctrl-tab").is_empty());
|
||
assert_eq!(effective_key("NextTab", cx), None);
|
||
});
|
||
}
|
||
|
||
#[gpui::test]
|
||
fn a_list_is_the_whole_set_of_chords_for_an_action(cx: &mut TestAppContext) {
|
||
use gpui::Action as _;
|
||
cx.update(|cx| {
|
||
running_on_json(
|
||
cx,
|
||
r#"{"keybindings": {"NextTab": ["ctrl-alt-]", "ctrl-alt-n"]}}"#,
|
||
);
|
||
assert!(
|
||
fired(cx, "ctrl-tab").is_empty(),
|
||
"a list replaces the default rather than joining it"
|
||
);
|
||
for chord in ["ctrl-alt-]", "ctrl-alt-n"] {
|
||
assert_eq!(
|
||
fired(cx, chord).first(),
|
||
Some(&NextTab::name_for_type()),
|
||
"{chord} is in the list"
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
#[gpui::test]
|
||
fn an_empty_list_unbinds_the_action(cx: &mut TestAppContext) {
|
||
cx.update(|cx| {
|
||
running_on_json(cx, r#"{"keybindings": {"NextTab": []}}"#);
|
||
assert!(fired(cx, "ctrl-tab").is_empty());
|
||
assert_eq!(effective_key("NextTab", cx), None);
|
||
});
|
||
}
|
||
|
||
/// #901: Alt+1…9 is how vim switches tabs, and tty7's default was eating
|
||
/// the whole row of them. Both halves of what the reporter wanted have to
|
||
/// hold, and hold across a restart — the complaint was that the default
|
||
/// "keeps coming back".
|
||
#[gpui::test]
|
||
fn alt_digits_can_be_moved_off_the_tab_actions_for_good(cx: &mut TestAppContext) {
|
||
use gpui::Action as _;
|
||
cx.update(|cx| {
|
||
let default_chord = per_platform("secondary-1", "alt-1");
|
||
running_on_json(
|
||
cx,
|
||
r#"{"keybindings": {"ActivateTab1": ["alt-shift-1"],
|
||
"ActivateTab2": []}}"#,
|
||
);
|
||
assert!(
|
||
fired(cx, default_chord).is_empty(),
|
||
"a list replaces the shipped chord, so the digit reaches the shell"
|
||
);
|
||
assert!(
|
||
fired(cx, per_platform("secondary-2", "alt-2")).is_empty(),
|
||
"and an empty list leaves the action with no chord at all"
|
||
);
|
||
assert_eq!(
|
||
fired(cx, "alt-shift-1").first(),
|
||
Some(&ActivateTab1::name_for_type()),
|
||
"the chord asked for in its place is live"
|
||
);
|
||
assert_eq!(effective_key("ActivateTab2", cx), None);
|
||
|
||
// The half that reads as "老是自动恢复": whatever the app saves has
|
||
// to load back as the same thing. A `Config` serialized and read
|
||
// again is exactly what a restart does with `config.json`.
|
||
let saved =
|
||
serde_json::to_string(&**cx.global::<Config>()).expect("the config serializes");
|
||
cx.set_global(Config(
|
||
serde_json::from_str(&saved).expect("the saved config parses"),
|
||
));
|
||
rebind(cx);
|
||
assert!(
|
||
fired(cx, default_chord).is_empty(),
|
||
"the shipped chord must not come back across a save and reload"
|
||
);
|
||
assert_eq!(
|
||
fired(cx, "alt-shift-1").first(),
|
||
Some(&ActivateTab1::name_for_type()),
|
||
);
|
||
assert_eq!(effective_key("ActivateTab2", cx), None);
|
||
});
|
||
}
|
||
|
||
#[gpui::test]
|
||
fn a_chord_added_under_the_tmux_preset_joins_the_preset_chord(cx: &mut TestAppContext) {
|
||
use gpui::Action as _;
|
||
cx.update(|cx| {
|
||
running_on_json(
|
||
cx,
|
||
r#"{"keybinding_preset": "tmux",
|
||
"keybindings": {"NextTab": "ctrl-alt-]", "SplitRight": ["ctrl-alt-d"]}}"#,
|
||
);
|
||
// The preset is a scheme, and it still replaces the default chord.
|
||
assert!(fired(cx, "ctrl-tab").is_empty());
|
||
// A chord added on top of it is added to the preset's chord.
|
||
assert_eq!(
|
||
fired(cx, "ctrl-b n").first(),
|
||
Some(&NextTab::name_for_type())
|
||
);
|
||
assert_eq!(
|
||
fired(cx, "ctrl-alt-]").first(),
|
||
Some(&NextTab::name_for_type())
|
||
);
|
||
// A list replaces the preset's chord the way it replaces a default.
|
||
assert!(fired(cx, "ctrl-b %").is_empty());
|
||
assert_eq!(
|
||
fired(cx, "ctrl-alt-d").first(),
|
||
Some(&SplitRight::name_for_type())
|
||
);
|
||
});
|
||
}
|
||
|
||
#[gpui::test]
|
||
fn a_chord_the_user_adds_wins_over_a_default_already_on_it(cx: &mut TestAppContext) {
|
||
use gpui::Action as _;
|
||
cx.update(|cx| {
|
||
// `ctrl-tab` is `NextTab`'s default, and `NewTab` sits above it in the
|
||
// table. The keymap resolves a tie to the binding added last, so a
|
||
// user chord laid down in table order lost to the default whenever
|
||
// its action happened to come first — the line in config.json was
|
||
// read and did nothing.
|
||
running_on_json(cx, r#"{"keybindings": {"NewTab": "ctrl-tab"}}"#);
|
||
assert_eq!(
|
||
fired(cx, "ctrl-tab").first(),
|
||
Some(&NewTab::name_for_type()),
|
||
"the chord the user asked for is the one that runs"
|
||
);
|
||
assert_eq!(
|
||
fired(cx, per_platform("secondary-t", "secondary-shift-t")).first(),
|
||
Some(&NewTab::name_for_type()),
|
||
"and NewTab keeps its own default"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[gpui::test]
|
||
fn repeated_rebinds_do_not_grow_the_keymap(cx: &mut TestAppContext) {
|
||
cx.update(|cx| {
|
||
gpui_component::init(cx);
|
||
cx.set_global(Config::default());
|
||
init(cx);
|
||
let size = cx.key_bindings().borrow().bindings().len();
|
||
// A rebind that clears first stays the same size however many
|
||
// times it runs; the append-only one this replaced grew by a full
|
||
// table each call, and every keystroke walks the whole map (#548).
|
||
for _ in 0..3 {
|
||
rebind(cx);
|
||
}
|
||
assert_eq!(
|
||
cx.key_bindings().borrow().bindings().len(),
|
||
size,
|
||
"rebind clears before it binds, so the map does not grow"
|
||
);
|
||
});
|
||
}
|
||
|
||
#[gpui::test]
|
||
fn a_rebuild_keeps_the_bindings_tty7_did_not_install(cx: &mut TestAppContext) {
|
||
cx.update(|cx| {
|
||
// gpui-component binds the whole `Input` editing table — backspace
|
||
// among it — from its own `init`, which runs once, before ours. A
|
||
// rebuild replaces the map, so it has to carry them; without this
|
||
// the first rebind left every text field in the app unable to
|
||
// delete a character (#548).
|
||
gpui_component::init(cx);
|
||
cx.set_global(Config::default());
|
||
|
||
let backspace = |cx: &App| {
|
||
let typed = [Keystroke::parse("backspace").unwrap()];
|
||
cx.key_bindings()
|
||
.borrow()
|
||
.all_bindings_for_input(&typed)
|
||
.iter()
|
||
.map(|b| b.action().name().to_string())
|
||
.collect::<Vec<_>>()
|
||
};
|
||
|
||
let inherited = backspace(cx);
|
||
assert!(
|
||
!inherited.is_empty(),
|
||
"gpui-component binds backspace before tty7 touches the keymap"
|
||
);
|
||
|
||
init(cx);
|
||
assert_eq!(backspace(cx), inherited, "init must not drop them");
|
||
rebind(cx);
|
||
assert_eq!(backspace(cx), inherited, "nor may a rebind");
|
||
});
|
||
}
|
||
}
|