mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 08:15:35 +00:00
* fix(palette): an open palette keeps workspace keys, and ⌘P answers from the Settings window While the palette was open, any app binding it did not handle — ⌘1–9, ⌘D, ⌘W — fell through to the workspace behind it. The Settings window has no palette listener, so the palette chord did nothing there. A keystroke interceptor, which gpui runs before matching any binding, now handles both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(palette): shifted editing letters are not the query field's The modal rule let every secondary+A/C/V/X/Z chord through as the query field's own, shift included, so on macOS Cmd+Shift+A (New Agent Tab) still opened a tab behind the open palette. Only redo keeps its shift. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
3349 lines
139 KiB
Rust
3349 lines
139 KiB
Rust
use gpui::{App, Global, KeyBinding, Keystroke};
|
||
|
||
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;
|
||
use crate::ui::search::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));
|
||
palette::init(cx);
|
||
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"),
|
||
));
|
||
// Tab walks the search's tabs. Bound on the search's own context for the
|
||
// switcher's reason: Root's focus walker would otherwise take it out of
|
||
// the modal, onto whichever chrome tile is behind it, ring and all.
|
||
let search = Some(crate::ui::search::KEY_CONTEXT);
|
||
bindings.push(KeyBinding::new("tab", SearchNextTab, search));
|
||
bindings.push(KeyBinding::new("shift-tab", SearchPrevTab, search));
|
||
// The code editor's multi-cursor chords, laid down again after tty7's
|
||
// own table. gpui ranks a context-free binding as deep as the focused
|
||
// context, and breaks the tie by whichever was added last — so the copies
|
||
// gpui-component installs lose ⌘D to `SplitRight` and ⌘⌥↑/↓ to
|
||
// `FocusPaneUp`/`Down`, whose handlers sit on the workspace root and take
|
||
// the key before the editor is asked. Re-added here they win inside an
|
||
// `Input`; only the code editor handles them, so in any other text field
|
||
// gpui falls through to the pane binding next in line.
|
||
{
|
||
use gpui_component::input::{
|
||
AddCursorAbove, AddCursorBelow, SelectAllOccurrences, SelectNextOccurrence,
|
||
};
|
||
let input = Some("Input");
|
||
bindings.push(KeyBinding::new("secondary-d", SelectNextOccurrence, input));
|
||
bindings.push(KeyBinding::new(
|
||
"secondary-shift-l",
|
||
SelectAllOccurrences,
|
||
input,
|
||
));
|
||
bindings.push(KeyBinding::new("secondary-alt-up", AddCursorAbove, input));
|
||
bindings.push(KeyBinding::new("secondary-alt-down", AddCursorBelow, input));
|
||
// ⌘K ⌘D: a chord whose first key is `ClearScrollback` on macOS. gpui
|
||
// drops a pending chord that ranks below a complete match, so this
|
||
// too has to come after tty7's table. Code editor only: in any other
|
||
// field ⌘K goes straight through, without waiting for a second key.
|
||
bindings.push(KeyBinding::new(
|
||
"secondary-k secondary-d",
|
||
gpui_component::input::SkipOccurrence,
|
||
Some(gpui_component::input::CODE_EDITOR_CONTEXT),
|
||
));
|
||
}
|
||
// The language-server commands, on the keys VS Code taught everyone, and
|
||
// only inside a text field: F2 and F12 in a terminal belong to the
|
||
// program running there. The handlers act only for the code editor and
|
||
// let the key go on anywhere else. ⌘. / Ctrl+. needs nothing here: the
|
||
// editor binds it to the same code-action menu itself.
|
||
let input = Some("Input");
|
||
bindings.push(KeyBinding::new("f12", EditorGoToDefinition, input));
|
||
bindings.push(KeyBinding::new("f2", EditorRenameSymbol, input));
|
||
bindings.push(KeyBinding::new("shift-alt-f", EditorFormatDocument, input));
|
||
bindings.push(KeyBinding::new("shift-f12", EditorFindReferences, input));
|
||
// The gutter's change markers, on VS Code's chords. Only in an `Input`:
|
||
// a function key bound for the whole window would never reach the shell,
|
||
// and the handlers let the key through when the code editor is not the
|
||
// field that has it.
|
||
{
|
||
let input = Some("Input");
|
||
bindings.push(KeyBinding::new("alt-f5", EditorNextChange, input));
|
||
bindings.push(KeyBinding::new("shift-alt-f5", EditorPrevChange, input));
|
||
bindings.push(KeyBinding::new("alt-f3", EditorPeekChange, input));
|
||
}
|
||
// The code editor's line commands, for the same tie: ⌘/ is the shortcut
|
||
// sheet, ⌘↵ fullscreen, ⌘⇧↵ maximize and ⌥↑/⌥↓ pane focus off macOS, all
|
||
// context-free. Bound on `CodeEditor`, which only a multi-line code
|
||
// editor declares, so every other text field and the terminal keep the
|
||
// app's keys.
|
||
{
|
||
use gpui_component::input::{
|
||
CODE_EDITOR_CONTEXT, CopyLineDown, CopyLineUp, DeleteLine, InsertLineAbove,
|
||
InsertLineBelow, MoveLineDown, MoveLineUp, MoveToMatchingBracket, SelectLine,
|
||
ToggleBlockComment, ToggleLineComment,
|
||
};
|
||
let editor = Some(CODE_EDITOR_CONTEXT);
|
||
bindings.extend([
|
||
KeyBinding::new("secondary-/", ToggleLineComment, editor),
|
||
KeyBinding::new("alt-shift-a", ToggleBlockComment, editor),
|
||
KeyBinding::new("alt-up", MoveLineUp, editor),
|
||
KeyBinding::new("alt-down", MoveLineDown, editor),
|
||
KeyBinding::new("alt-shift-up", CopyLineUp, editor),
|
||
KeyBinding::new("alt-shift-down", CopyLineDown, editor),
|
||
KeyBinding::new("secondary-shift-k", DeleteLine, editor),
|
||
KeyBinding::new("secondary-enter", InsertLineBelow, editor),
|
||
KeyBinding::new("secondary-shift-enter", InsertLineAbove, editor),
|
||
KeyBinding::new("secondary-l", SelectLine, editor),
|
||
KeyBinding::new("secondary-shift-\\", MoveToMatchingBracket, editor),
|
||
]);
|
||
#[cfg(target_os = "macos")]
|
||
bindings.push(KeyBinding::new(
|
||
"ctrl-j",
|
||
gpui_component::input::JoinLines,
|
||
editor,
|
||
));
|
||
}
|
||
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 — and `EditorGoToLine` on Ctrl+G, as in
|
||
/// every other code editor — 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 {
|
||
matches!(action, "EditorSave" | "EditorGoToLine")
|
||
// Bound in the text field's context only, and handled only by the
|
||
// code editor: in the terminal the chord is never matched, and in any
|
||
// other field it falls through. VS Code's Go Forward, ⌃⇧-, folds to
|
||
// Ctrl+_ (US) there.
|
||
|| action_context(action) == Some(crate::ui::code_editor::NAV_KEY_CONTEXT)
|
||
|| (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)> {
|
||
let mut bindings = shipped_bindings();
|
||
// One slot per agent, unbound: the palette's `Agent: …` rows are what these
|
||
// run, and each takes a key the same way any other action does. Right
|
||
// after the other agent actions, so the Keybindings page keeps them together.
|
||
let at = bindings
|
||
.iter()
|
||
.position(|(action, _)| *action == "NewAgentTab")
|
||
.map_or(bindings.len(), |i| i + 1);
|
||
bindings.splice(
|
||
at..at,
|
||
crate::ui::agent_launch::launch_action_names()
|
||
.iter()
|
||
.map(|(_, name)| (name.as_str(), "")),
|
||
);
|
||
bindings
|
||
}
|
||
|
||
fn shipped_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", ""),
|
||
("HibernateTab", ""),
|
||
("ForkAgentSession", ""),
|
||
("ForkAgentSessionRight", ""),
|
||
("ForkAgentSessionLeft", ""),
|
||
("ForkAgentSessionDown", ""),
|
||
("ForkAgentSessionUp", ""),
|
||
("CopyAgentSessionId", ""),
|
||
// ⌘⇧A for "agent" on macOS, where nothing else in the table or the
|
||
// terminal holds it. Off macOS Ctrl+Shift+A is select-all in every
|
||
// Linux terminal and in this prompt editor's Ctrl+A family, so it ships
|
||
// unbound there rather than on a chord nobody would guess.
|
||
("NewAgentTab", per_platform("secondary-shift-a", "")),
|
||
("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", ""),
|
||
// Ctrl+Tab opens the most-recently-used switcher (a quick tap lands
|
||
// on the tab you were just in); these step to the neighbouring tab
|
||
// in strip order with no popup (#867). ⌘⇧] / ⌘⇧[ is iTerm2's and
|
||
// Safari's chord. Off macOS Ctrl+Shift+] is FocusNextPane, so they
|
||
// take Ctrl+PgDn / Ctrl+PgUp, the chord GNOME Terminal, xfce4-terminal
|
||
// and every browser use for the same thing.
|
||
("NextTab", "ctrl-tab"),
|
||
("PrevTab", "ctrl-shift-tab"),
|
||
(
|
||
"SelectNextTab",
|
||
per_platform("secondary-shift-]", "ctrl-pagedown"),
|
||
),
|
||
(
|
||
"SelectPrevTab",
|
||
per_platform("secondary-shift-[", "ctrl-pageup"),
|
||
),
|
||
// Reordering, not switching: bound by the user who wants it (the
|
||
// palette and the Keybindings page both carry these), like the pane
|
||
// swap pair above. No chord ships free enough to spend on both.
|
||
("MoveTabLeft", ""),
|
||
("MoveTabRight", ""),
|
||
("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"),
|
||
),
|
||
// VS Code's ⌘P, which here is already Search Everywhere. ⌘O instead:
|
||
// it is "Open…" in every Mac app, and nothing in the table or the
|
||
// terminal holds it. Off macOS the obvious chords are gone — Ctrl+P
|
||
// and Ctrl+O are bytes the shell is owed, Ctrl+Shift+P is Search
|
||
// Everywhere and Ctrl+Shift+O the workspace switcher — so it ships
|
||
// unbound there, like `NewWindow`, rather than on a chord nobody would
|
||
// guess. The Files tab is a Tab press away inside the search either way.
|
||
("QuickOpenFile", per_platform("secondary-o", "")),
|
||
(
|
||
"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", ""),
|
||
// Unbound for the same reason, and because the obvious chords belong
|
||
// to the editor that has the focus when you would reach for them.
|
||
("ToggleDocumentPreview", ""),
|
||
("ToggleDocumentWrap", ""),
|
||
// 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", ""),
|
||
("ShowRightPanelFiles", ""),
|
||
("ShowRightPanelSearch", ""),
|
||
("ShowRightPanelChanges", ""),
|
||
("ShowRightPanelGitHub", ""),
|
||
("EditorSave", "secondary-s"),
|
||
("EditorSaveAs", "secondary-shift-s"),
|
||
("EditorGoToLine", "ctrl-g"),
|
||
// VS Code's chords. ⌘⇧O is the workspace switcher everywhere but the
|
||
// editor, which takes it back while it has the focus (see
|
||
// `action_context`). Back and Forward are ⌃- and ⌃⇧- on macOS; off
|
||
// it ⌃- is Decrease Font Size, so Alt+←/→ as VS Code has them there.
|
||
("EditorGoToSymbol", "secondary-shift-o"),
|
||
("EditorNavigateBack", per_platform("ctrl--", "alt-left")),
|
||
(
|
||
"EditorNavigateForward",
|
||
per_platform("ctrl-shift--", "alt-right"),
|
||
),
|
||
// VS Code's Split Editor. Off macOS Ctrl+\ is the terminal's SIGQUIT,
|
||
// which it keeps: the binding lives in the editor's context only.
|
||
("EditorSplitRight", "secondary-\\"),
|
||
// Moving between the two groups, on the chords that move between
|
||
// panes — at the edge of the editor, or with no split, they carry on
|
||
// to the panes. Off macOS those are Alt+←/→, which Back and Forward
|
||
// hold inside the editor.
|
||
(
|
||
"EditorFocusLeftGroup",
|
||
per_platform("secondary-alt-left", "ctrl-alt-left"),
|
||
),
|
||
(
|
||
"EditorFocusRightGroup",
|
||
per_platform("secondary-alt-right", "ctrl-alt-right"),
|
||
),
|
||
// Unbound: ⌘N is New Window, and the editor's header has a + for it.
|
||
("EditorNewFile", ""),
|
||
// The language-server commands. Unbound here — a default on a bare
|
||
// function key would take it from the shell — and bound instead in
|
||
// the editor's own context by `fixed_bindings`.
|
||
("EditorGoToDefinition", ""),
|
||
("EditorQuickFix", ""),
|
||
("EditorRenameSymbol", ""),
|
||
("EditorFormatDocument", ""),
|
||
// The editor's text commands. Unbound: none of them has a chord in
|
||
// VS Code either, except Join Lines, whose ⌃J is a fixed `CodeEditor`
|
||
// binding on macOS (in the terminal it is a line feed).
|
||
("EditorTransformUppercase", ""),
|
||
("EditorTransformLowercase", ""),
|
||
("EditorTransformTitleCase", ""),
|
||
("EditorTrimTrailingWhitespace", ""),
|
||
("EditorJoinLines", ""),
|
||
("EditorRemoveSurroundingBrackets", ""),
|
||
("EditorFindReferences", ""),
|
||
// Alt+F5 / Shift+Alt+F5 (VS Code's) are fixed `Input`-context
|
||
// bindings: a default on a function key would hide it from the shell.
|
||
("EditorNextChange", ""),
|
||
("EditorPrevChange", ""),
|
||
("EditorRevertChange", ""),
|
||
("ToggleEditorGitGutter", ""),
|
||
// Alt+F3 is a fixed `Input`-context binding, like Alt+F5.
|
||
("EditorPeekChange", ""),
|
||
// VS Code's chord for its Problems panel.
|
||
("ToggleEditorProblems", "secondary-shift-m"),
|
||
("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(agent) = crate::ui::agent_launch::agent_for_launch_action(action) {
|
||
return Some((
|
||
CommandGroup::Agents,
|
||
t_fmt(
|
||
L10nKey::AppCmdAgentLaunchTitle,
|
||
&[("name", agent.display_name())],
|
||
),
|
||
));
|
||
}
|
||
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(),
|
||
),
|
||
"HibernateTab" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdHibernateTab).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` / `PrevTab` keep their names — `Config::keybindings` is
|
||
// keyed by them — but what they do is open the MRU switcher, so that
|
||
// is what the page calls them. "Next Tab" belongs to the action that
|
||
// actually goes to the next tab, the same one the palette runs.
|
||
"NextTab" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdRecentTabSwitcher).to_string(),
|
||
),
|
||
"PrevTab" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdRecentTabSwitcherReverse).to_string(),
|
||
),
|
||
"SelectNextTab" => (CommandGroup::TabsPanes, t(L10nKey::CmdNextTab).to_string()),
|
||
"SelectPrevTab" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdPreviousTab).to_string(),
|
||
),
|
||
"MoveTabLeft" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdMoveTabLeft).to_string(),
|
||
),
|
||
"MoveTabRight" => (
|
||
CommandGroup::TabsPanes,
|
||
t(L10nKey::CmdMoveTabRight).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(),
|
||
),
|
||
"ToggleDocumentPreview" => (
|
||
CommandGroup::View,
|
||
t(L10nKey::CmdToggleDocumentPreview).to_string(),
|
||
),
|
||
"ToggleDocumentWrap" => (
|
||
CommandGroup::View,
|
||
t(L10nKey::CmdToggleDocumentWrap).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(),
|
||
),
|
||
"ShowRightPanelSearch" => (
|
||
CommandGroup::View,
|
||
t(L10nKey::CmdRightPanelSearch).to_string(),
|
||
),
|
||
"ShowRightPanelGitHub" => (
|
||
CommandGroup::View,
|
||
t(L10nKey::CmdRightPanelGitHub).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()),
|
||
"EditorSaveAs" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::EditorSaveAsAction).to_string(),
|
||
),
|
||
"EditorGoToLine" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::EditorGoToLineAction).to_string(),
|
||
),
|
||
"EditorGoToDefinition" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::LspGoToDefinition).to_string(),
|
||
),
|
||
"EditorQuickFix" => (CommandGroup::Terminal, t(L10nKey::LspQuickFix).to_string()),
|
||
"EditorRenameSymbol" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::LspRenameSymbolAction).to_string(),
|
||
),
|
||
"EditorFormatDocument" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::LspFormatDocument).to_string(),
|
||
),
|
||
"EditorTransformUppercase" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::CmdEditorTransformUppercase).to_string(),
|
||
),
|
||
"EditorTransformLowercase" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::CmdEditorTransformLowercase).to_string(),
|
||
),
|
||
"EditorTransformTitleCase" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::CmdEditorTransformTitleCase).to_string(),
|
||
),
|
||
"EditorTrimTrailingWhitespace" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::CmdEditorTrimTrailingWhitespace).to_string(),
|
||
),
|
||
"EditorJoinLines" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::CmdEditorJoinLines).to_string(),
|
||
),
|
||
"EditorRemoveSurroundingBrackets" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::CmdEditorRemoveSurroundingBrackets).to_string(),
|
||
),
|
||
"EditorFindReferences" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::LspFindReferences).to_string(),
|
||
),
|
||
"EditorGoToSymbol" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::EditorGoToSymbolAction).to_string(),
|
||
),
|
||
"EditorNavigateBack" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::EditorNavigateBack).to_string(),
|
||
),
|
||
"EditorNavigateForward" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::EditorNavigateForward).to_string(),
|
||
),
|
||
"EditorSplitRight" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::EditorSplitRight).to_string(),
|
||
),
|
||
"EditorFocusLeftGroup" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::EditorFocusLeftGroup).to_string(),
|
||
),
|
||
"EditorFocusRightGroup" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::EditorFocusRightGroup).to_string(),
|
||
),
|
||
"EditorNewFile" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::EditorNewFile).to_string(),
|
||
),
|
||
"EditorNextChange" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::EditorGitNextChange).to_string(),
|
||
),
|
||
"EditorPrevChange" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::EditorGitPrevChange).to_string(),
|
||
),
|
||
"EditorRevertChange" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::EditorGitRevertChange).to_string(),
|
||
),
|
||
"ToggleEditorGitGutter" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::EditorGitToggleGutter).to_string(),
|
||
),
|
||
"EditorPeekChange" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::EditorGitPeekChange).to_string(),
|
||
),
|
||
"ToggleEditorProblems" => (
|
||
CommandGroup::Terminal,
|
||
t(L10nKey::EditorProblemsToggle).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(),
|
||
),
|
||
"NewAgentTab" => (CommandGroup::Agents, t(L10nKey::CmdNewAgentTab).to_string()),
|
||
"TogglePalette" => (
|
||
CommandGroup::Application,
|
||
t(L10nKey::AppMenuSearchEverywhere).to_string(),
|
||
),
|
||
"QuickOpenFile" => (CommandGroup::View, t(L10nKey::CmdGoToFile).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(";")),
|
||
// tmux's `next-window` / `previous-window`: straight to the
|
||
// neighbour, no chooser. The MRU switcher it used to open never
|
||
// committed here — the modifier it waits on is not held (#867).
|
||
("SelectNextTab", p("n")),
|
||
("SelectPrevTab", 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;
|
||
}
|
||
// macOS names modifiers in one fixed order — ⌃ ⌥ ⇧ ⌘ — whatever order a
|
||
// binding was written in; `secondary-shift-o` read `⌘ ⇧ O` here while the
|
||
// menu bar said `⇧⌘O` for the same key.
|
||
#[cfg(target_os = "macos")]
|
||
tokens.sort_by_key(|glyph| {
|
||
["fn", "⌃", "⌥", "⇧", "⌘"]
|
||
.iter()
|
||
.position(|g| g == glyph)
|
||
.unwrap_or(usize::MAX)
|
||
});
|
||
tokens.push(key_glyph(rest));
|
||
tokens
|
||
}
|
||
|
||
fn key_glyph(key: &str) -> String {
|
||
match key {
|
||
// The glyph every hint in the app already draws for Return; this one
|
||
// alone said `⏎`, so the same key looked different in a row's
|
||
// shortcut and in the footer under it.
|
||
"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"),
|
||
"EditorGoToSymbol"
|
||
| "EditorNavigateBack"
|
||
| "EditorNavigateForward"
|
||
| "EditorSplitRight"
|
||
| "EditorFocusLeftGroup"
|
||
| "EditorFocusRightGroup" => Some(crate::ui::code_editor::NAV_KEY_CONTEXT),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
|
||
if let Some(agent) = crate::ui::agent_launch::agent_for_launch_action(action) {
|
||
return Some(KeyBinding::new(keystroke, LaunchAgent { agent }, None));
|
||
}
|
||
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),
|
||
"HibernateTab" => KeyBinding::new(keystroke, HibernateTab, 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),
|
||
"NewAgentTab" => KeyBinding::new(keystroke, NewAgentTab, 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),
|
||
"SelectNextTab" => KeyBinding::new(keystroke, SelectNextTab, None),
|
||
"SelectPrevTab" => KeyBinding::new(keystroke, SelectPrevTab, None),
|
||
"MoveTabLeft" => KeyBinding::new(keystroke, MoveTabLeft, None),
|
||
"MoveTabRight" => KeyBinding::new(keystroke, MoveTabRight, 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),
|
||
"QuickOpenFile" => KeyBinding::new(keystroke, QuickOpenFile, 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),
|
||
"ShowRightPanelSearch" => KeyBinding::new(keystroke, ShowRightPanelSearch, None),
|
||
"ShowRightPanelGitHub" => KeyBinding::new(keystroke, ShowRightPanelGitHub, 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),
|
||
"ToggleDocumentPreview" => KeyBinding::new(keystroke, ToggleDocumentPreview, None),
|
||
"ToggleDocumentWrap" => KeyBinding::new(keystroke, ToggleDocumentWrap, None),
|
||
"EditorSave" => KeyBinding::new(keystroke, EditorSave, None),
|
||
"EditorSaveAs" => KeyBinding::new(keystroke, EditorSaveAs, None),
|
||
"EditorGoToLine" => KeyBinding::new(keystroke, EditorGoToLine, None),
|
||
"EditorGoToSymbol" => KeyBinding::new(
|
||
keystroke,
|
||
EditorGoToSymbol,
|
||
Some(crate::ui::code_editor::NAV_KEY_CONTEXT),
|
||
),
|
||
"EditorNavigateBack" => KeyBinding::new(
|
||
keystroke,
|
||
EditorNavigateBack,
|
||
Some(crate::ui::code_editor::NAV_KEY_CONTEXT),
|
||
),
|
||
"EditorNavigateForward" => KeyBinding::new(
|
||
keystroke,
|
||
EditorNavigateForward,
|
||
Some(crate::ui::code_editor::NAV_KEY_CONTEXT),
|
||
),
|
||
"EditorSplitRight" => KeyBinding::new(
|
||
keystroke,
|
||
EditorSplitRight,
|
||
Some(crate::ui::code_editor::NAV_KEY_CONTEXT),
|
||
),
|
||
"EditorFocusLeftGroup" => KeyBinding::new(
|
||
keystroke,
|
||
EditorFocusLeftGroup,
|
||
Some(crate::ui::code_editor::NAV_KEY_CONTEXT),
|
||
),
|
||
"EditorFocusRightGroup" => KeyBinding::new(
|
||
keystroke,
|
||
EditorFocusRightGroup,
|
||
Some(crate::ui::code_editor::NAV_KEY_CONTEXT),
|
||
),
|
||
"EditorNewFile" => KeyBinding::new(keystroke, EditorNewFile, None),
|
||
"EditorGoToDefinition" => KeyBinding::new(keystroke, EditorGoToDefinition, None),
|
||
"EditorQuickFix" => KeyBinding::new(keystroke, EditorQuickFix, None),
|
||
"EditorRenameSymbol" => KeyBinding::new(keystroke, EditorRenameSymbol, None),
|
||
"EditorFormatDocument" => KeyBinding::new(keystroke, EditorFormatDocument, None),
|
||
"EditorTransformUppercase" => KeyBinding::new(keystroke, EditorTransformUppercase, None),
|
||
"EditorTransformLowercase" => KeyBinding::new(keystroke, EditorTransformLowercase, None),
|
||
"EditorTransformTitleCase" => KeyBinding::new(keystroke, EditorTransformTitleCase, None),
|
||
"EditorTrimTrailingWhitespace" => {
|
||
KeyBinding::new(keystroke, EditorTrimTrailingWhitespace, None)
|
||
}
|
||
"EditorJoinLines" => KeyBinding::new(keystroke, EditorJoinLines, None),
|
||
"EditorRemoveSurroundingBrackets" => {
|
||
KeyBinding::new(keystroke, EditorRemoveSurroundingBrackets, None)
|
||
}
|
||
"EditorFindReferences" => KeyBinding::new(keystroke, EditorFindReferences, None),
|
||
"EditorNextChange" => KeyBinding::new(keystroke, EditorNextChange, None),
|
||
"EditorPrevChange" => KeyBinding::new(keystroke, EditorPrevChange, None),
|
||
"EditorRevertChange" => KeyBinding::new(keystroke, EditorRevertChange, None),
|
||
"ToggleEditorGitGutter" => KeyBinding::new(keystroke, ToggleEditorGitGutter, None),
|
||
"EditorPeekChange" => KeyBinding::new(keystroke, EditorPeekChange, None),
|
||
"ToggleEditorProblems" => KeyBinding::new(keystroke, ToggleEditorProblems, 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",
|
||
"ShowRightPanelSearch",
|
||
"ShowRightPanelGitHub",
|
||
] {
|
||
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, "Search…");
|
||
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"]);
|
||
#[cfg(target_os = "macos")]
|
||
assert_eq!(key_tokens("secondary-shift-d"), vec![SHIFT, SECONDARY, "D"]);
|
||
#[cfg(not(target_os = "macos"))]
|
||
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"));
|
||
assert!(control_code_binding_allowed("EditorGoToLine", "ctrl-g"));
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
#[test]
|
||
fn new_agent_tab_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 == "NewAgentTab")
|
||
.map(|(_, key)| key.clone())
|
||
.expect("NewAgentTab has to be listed or it cannot be bound");
|
||
if cfg!(target_os = "macos") {
|
||
assert_eq!(default, "secondary-shift-a");
|
||
assert_eq!(
|
||
dispatched(&effective, &default, "Terminal"),
|
||
vec![NewAgentTab::name_for_type()],
|
||
"{default} must reach NewAgentTab, and nothing else may answer it"
|
||
);
|
||
} else {
|
||
assert_eq!(default, "", "Ctrl+Shift+A is select-all off macOS");
|
||
}
|
||
|
||
// Each agent's own launch is a slot of its own, and a key bound to it
|
||
// dispatches that agent.
|
||
let name = crate::ui::agent_launch::launch_action_name(
|
||
tty7_core::core::cli_agent::CLIAgent::Codex,
|
||
);
|
||
effective
|
||
.iter_mut()
|
||
.find(|(action, _)| action == name)
|
||
.expect("every agent has a keymap slot")
|
||
.1 = "ctrl-alt-shift-c".to_string();
|
||
let mut keymap = gpui::Keymap::default();
|
||
keymap.add_bindings(action_bindings(&effective));
|
||
let typed = [Keystroke::parse("ctrl-alt-shift-c").unwrap()];
|
||
let context = [gpui::KeyContext::parse("Terminal").unwrap()];
|
||
let hits = keymap.bindings_for_input(&typed, &context).0;
|
||
assert_eq!(hits.len(), 1);
|
||
assert!(hits[0].action().partial_eq(&LaunchAgent {
|
||
agent: tty7_core::core::cli_agent::CLIAgent::Codex,
|
||
}));
|
||
assert_eq!(action_entry(name).0, CommandGroup::Agents);
|
||
}
|
||
}
|
||
|
||
#[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()
|
||
}
|
||
|
||
/// The code editor's line commands sit on chords the app also spends —
|
||
/// ⌘/ is the shortcut sheet, ⌘↵ fullscreen, ⌘⇧↵ maximize, ⌥↑/⌥↓ pane
|
||
/// focus off macOS. `fixed_bindings` re-adds them on the `CodeEditor`
|
||
/// context the editor declares beside `Input`, after the app's table, so
|
||
/// inside the editor they win and everywhere else nothing changes.
|
||
#[gpui::test]
|
||
fn the_code_editor_chords_win_inside_the_editor(cx: &mut TestAppContext) {
|
||
cx.update(|cx| {
|
||
running_on_json(cx, "{}");
|
||
let first = |keys: &str, stack: &[&str]| {
|
||
let input: Vec<Keystroke> = keys
|
||
.split(' ')
|
||
.map(|k| Keystroke::parse(k).expect("the typed keystroke parses"))
|
||
.collect();
|
||
let stack: Vec<gpui::KeyContext> = stack
|
||
.iter()
|
||
.map(|c| gpui::KeyContext::parse(c).expect("the context parses"))
|
||
.collect();
|
||
cx.key_bindings()
|
||
.borrow()
|
||
.bindings_for_input(&input, &stack)
|
||
.0
|
||
.first()
|
||
.map(|b| b.action().name())
|
||
};
|
||
let editor = ["Workspace", "Input CodeEditor"];
|
||
let join_lines = cfg!(target_os = "macos").then_some(("ctrl-j", "input::JoinLines"));
|
||
for (keys, action) in join_lines.into_iter().chain([
|
||
("secondary-/", "input::ToggleLineComment"),
|
||
("alt-up", "input::MoveLineUp"),
|
||
("alt-down", "input::MoveLineDown"),
|
||
("alt-shift-up", "input::CopyLineUp"),
|
||
("alt-shift-down", "input::CopyLineDown"),
|
||
("secondary-shift-k", "input::DeleteLine"),
|
||
("secondary-enter", "input::InsertLineBelow"),
|
||
("secondary-shift-enter", "input::InsertLineAbove"),
|
||
("secondary-l", "input::SelectLine"),
|
||
("secondary-shift-\\", "input::MoveToMatchingBracket"),
|
||
]) {
|
||
assert_eq!(first(keys, &editor), Some(action), "{keys} in the editor");
|
||
assert_ne!(
|
||
first(keys, &["Workspace", "Input"]),
|
||
Some(action),
|
||
"{keys} in a plain text field"
|
||
);
|
||
assert_ne!(
|
||
first(keys, &["Workspace", "Terminal"]),
|
||
Some(action),
|
||
"{keys} in a terminal"
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
#[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": {"SelectNextTab": "ctrl-alt-]", "SplitRight": ["ctrl-alt-d"]}}"#,
|
||
);
|
||
// The preset is a scheme, and it still replaces the default chord.
|
||
assert!(fired(cx, per_platform("secondary-shift-]", "ctrl-pagedown")).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(&SelectNextTab::name_for_type())
|
||
);
|
||
assert_eq!(
|
||
fired(cx, "ctrl-alt-]").first(),
|
||
Some(&SelectNextTab::name_for_type())
|
||
);
|
||
// The MRU switcher the preset no longer takes over keeps Ctrl+Tab.
|
||
assert_eq!(
|
||
fired(cx, "ctrl-tab").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");
|
||
});
|
||
}
|
||
|
||
/// The gutter's next / previous change chords live only in an `Input`:
|
||
/// the editor gets them, a terminal never loses a function key to them.
|
||
#[gpui::test]
|
||
fn editor_change_chords_are_bound_only_in_an_input(cx: &mut TestAppContext) {
|
||
use gpui::Action as _;
|
||
cx.update(|cx| {
|
||
running_on_json(cx, "{}");
|
||
let hits = |keys: &str, contexts: &[&str]| -> Vec<&'static str> {
|
||
let input = [Keystroke::parse(keys).expect("the keystroke parses")];
|
||
let context: Vec<_> = contexts
|
||
.iter()
|
||
.map(|c| gpui::KeyContext::parse(c).expect("the context parses"))
|
||
.collect();
|
||
cx.key_bindings()
|
||
.borrow()
|
||
.bindings_for_input(&input, &context)
|
||
.0
|
||
.iter()
|
||
.map(|b| b.action().name())
|
||
.collect()
|
||
};
|
||
assert_eq!(
|
||
hits("alt-f5", &["Workspace", "Input"]).first(),
|
||
Some(&EditorNextChange::name_for_type())
|
||
);
|
||
assert_eq!(
|
||
hits("shift-alt-f5", &["Workspace", "Input"]).first(),
|
||
Some(&EditorPrevChange::name_for_type())
|
||
);
|
||
assert_eq!(
|
||
hits("alt-f3", &["Workspace", "Input"]).first(),
|
||
Some(&EditorPeekChange::name_for_type())
|
||
);
|
||
assert!(hits("alt-f5", &["Workspace"]).is_empty());
|
||
});
|
||
}
|
||
|
||
/// The code editor's multi-cursor chords sit on keys tty7 ships for panes
|
||
/// (⌘D split, ⌘⌥↑/↓ focus). `fixed_bindings` re-adds them after the
|
||
/// pane table so they rank first inside an `Input`; any other text field
|
||
/// has no handler for them, and gpui falls through to the pane binding
|
||
/// listed after. A terminal never sees them.
|
||
#[gpui::test]
|
||
fn editor_multi_cursor_chords_outrank_the_pane_bindings(cx: &mut TestAppContext) {
|
||
use gpui::Action as _;
|
||
use gpui_component::input::{
|
||
AddCursorAbove, AddCursorBelow, SelectAllOccurrences, SelectNextOccurrence,
|
||
};
|
||
cx.update(|cx| {
|
||
running_on_json(cx, "{}");
|
||
let in_input = |keys: &str| -> Vec<&'static str> {
|
||
let input = [Keystroke::parse(keys).expect("the keystroke parses")];
|
||
let context = [
|
||
gpui::KeyContext::parse("Workspace").expect("the context parses"),
|
||
gpui::KeyContext::parse("Input").expect("the context parses"),
|
||
];
|
||
cx.key_bindings()
|
||
.borrow()
|
||
.bindings_for_input(&input, &context)
|
||
.0
|
||
.iter()
|
||
.map(|b| b.action().name())
|
||
.collect()
|
||
};
|
||
let cases = [
|
||
("secondary-d", SelectNextOccurrence::name_for_type()),
|
||
("secondary-shift-l", SelectAllOccurrences::name_for_type()),
|
||
("secondary-alt-up", AddCursorAbove::name_for_type()),
|
||
("secondary-alt-down", AddCursorBelow::name_for_type()),
|
||
];
|
||
for (keys, editor_action) in cases {
|
||
let hits = in_input(keys);
|
||
assert_eq!(hits.first(), Some(&editor_action), "{keys} in the editor");
|
||
}
|
||
if cfg!(target_os = "macos") {
|
||
assert!(in_input("secondary-d").contains(&SplitRight::name_for_type()));
|
||
assert_eq!(
|
||
fired(cx, "secondary-d").first(),
|
||
Some(&SplitRight::name_for_type()),
|
||
"a terminal still splits on ⌘D"
|
||
);
|
||
}
|
||
|
||
// ⌘K ⌘D in the code editor skips to the next occurrence, and ⌘K
|
||
// there waits for the chord's second key.
|
||
let editor = [
|
||
gpui::KeyContext::parse("Workspace").expect("the context parses"),
|
||
gpui::KeyContext::parse("Input CodeEditor").expect("the context parses"),
|
||
];
|
||
let keymap = cx.key_bindings();
|
||
let keymap = keymap.borrow();
|
||
let chord = [
|
||
Keystroke::parse("secondary-k").unwrap(),
|
||
Keystroke::parse("secondary-d").unwrap(),
|
||
];
|
||
let (hits, _) = keymap.bindings_for_input(&chord, &editor);
|
||
assert_eq!(
|
||
hits.first().map(|b| b.action().name()),
|
||
Some(gpui_component::input::SkipOccurrence::name_for_type())
|
||
);
|
||
let (_, pending) =
|
||
keymap.bindings_for_input(&[Keystroke::parse("secondary-k").unwrap()], &editor);
|
||
assert!(pending, "⌘K waits for the chord in the code editor");
|
||
// A plain text field isn't held up.
|
||
let field = [
|
||
gpui::KeyContext::parse("Workspace").expect("the context parses"),
|
||
gpui::KeyContext::parse("Input").expect("the context parses"),
|
||
];
|
||
let (_, pending) =
|
||
keymap.bindings_for_input(&[Keystroke::parse("secondary-k").unwrap()], &field);
|
||
assert!(!pending, "⌘K goes straight through in other fields");
|
||
});
|
||
}
|
||
|
||
/// Go to Symbol and Back/Forward sit on chords the window already uses
|
||
/// (⌘⇧O is the workspace switcher; off macOS Alt+←/→ move between
|
||
/// panes). Bound at the text field's depth and after the window's table,
|
||
/// they rank first inside the editor, and a terminal never sees them.
|
||
#[gpui::test]
|
||
fn editor_navigation_chords_outrank_the_window_inside_a_text_field(cx: &mut TestAppContext) {
|
||
use gpui::Action as _;
|
||
cx.update(|cx| {
|
||
running_on_json(cx, "{}");
|
||
let in_input = |keys: &str| -> Vec<&'static str> {
|
||
let input = [Keystroke::parse(keys).expect("the keystroke parses")];
|
||
let context = [
|
||
gpui::KeyContext::parse("Workspace").expect("the context parses"),
|
||
gpui::KeyContext::parse("Input").expect("the context parses"),
|
||
];
|
||
cx.key_bindings()
|
||
.borrow()
|
||
.bindings_for_input(&input, &context)
|
||
.0
|
||
.iter()
|
||
.map(|b| b.action().name())
|
||
.collect()
|
||
};
|
||
let (back, forward) = match cfg!(target_os = "macos") {
|
||
true => ("ctrl--", "ctrl-shift--"),
|
||
false => ("alt-left", "alt-right"),
|
||
};
|
||
for (keys, action) in [
|
||
("secondary-shift-o", EditorGoToSymbol::name_for_type()),
|
||
(back, EditorNavigateBack::name_for_type()),
|
||
(forward, EditorNavigateForward::name_for_type()),
|
||
("secondary-\\", EditorSplitRight::name_for_type()),
|
||
(
|
||
per_platform("secondary-alt-left", "ctrl-alt-left"),
|
||
EditorFocusLeftGroup::name_for_type(),
|
||
),
|
||
(
|
||
per_platform("secondary-alt-right", "ctrl-alt-right"),
|
||
EditorFocusRightGroup::name_for_type(),
|
||
),
|
||
] {
|
||
assert_eq!(
|
||
in_input(keys).first(),
|
||
Some(&action),
|
||
"{keys} in the editor"
|
||
);
|
||
assert!(!fired(cx, keys).contains(&action), "{keys} in a terminal");
|
||
}
|
||
assert_eq!(
|
||
fired(cx, "secondary-shift-o").first(),
|
||
Some(&ToggleSwitcher::name_for_type()),
|
||
"a terminal still opens the switcher"
|
||
);
|
||
});
|
||
}
|
||
}
|