Skip to content

Commit 715e7cc

Browse files
Merge pull request #2773 from CapSoftware/fix/gpui-text-input-ctrl-chords
fix(desktop-gpui): Ctrl editing shortcuts in text fields on Windows and Linux
2 parents 1f3e559 + 0319309 commit 715e7cc

1 file changed

Lines changed: 193 additions & 2 deletions

File tree

‎apps/desktop-gpui/src/ui/text_input.rs‎

Lines changed: 193 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,12 @@ pub const KEY_CONTEXT: &str = "TextInput";
139139
/// matched *before* the IME composes the character, so binding `a` would mean
140140
/// the letter `a` could never be typed.
141141
pub fn bind_keys(cx: &mut App) {
142+
cx.bind_keys(bindings());
143+
}
144+
145+
pub(crate) fn bindings() -> Vec<KeyBinding> {
142146
let ctx = Some(KEY_CONTEXT);
143-
cx.bind_keys([
147+
let mut bindings = vec![
144148
KeyBinding::new("backspace", Backspace, ctx),
145149
KeyBinding::new("delete", DeleteForward, ctx),
146150
KeyBinding::new("alt-backspace", DeleteWordLeft, ctx),
@@ -181,7 +185,36 @@ pub fn bind_keys(cx: &mut App) {
181185
KeyBinding::new("enter", Confirm, ctx),
182186
KeyBinding::new("escape", Cancel, ctx),
183187
KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, ctx),
184-
]);
188+
];
189+
if cfg!(not(target_os = "macos")) {
190+
bindings.extend(ctrl_bindings(ctx));
191+
}
192+
bindings
193+
}
194+
195+
/// The fork parses `cmd` as the Windows/Super key off macOS, so the `cmd-*`
196+
/// chords above never fire there. These are the Windows and Linux spellings of
197+
/// the same commands.
198+
fn ctrl_bindings(ctx: Option<&str>) -> [KeyBinding; 17] {
199+
[
200+
KeyBinding::new("ctrl-a", SelectAll, ctx),
201+
KeyBinding::new("ctrl-c", Copy, ctx),
202+
KeyBinding::new("ctrl-x", Cut, ctx),
203+
KeyBinding::new("ctrl-v", Paste, ctx),
204+
KeyBinding::new("ctrl-z", Undo, ctx),
205+
KeyBinding::new("ctrl-shift-z", Redo, ctx),
206+
KeyBinding::new("ctrl-y", Redo, ctx),
207+
KeyBinding::new("ctrl-left", MoveWordLeft, ctx),
208+
KeyBinding::new("ctrl-right", MoveWordRight, ctx),
209+
KeyBinding::new("ctrl-shift-left", SelectWordLeft, ctx),
210+
KeyBinding::new("ctrl-shift-right", SelectWordRight, ctx),
211+
KeyBinding::new("ctrl-backspace", DeleteWordLeft, ctx),
212+
KeyBinding::new("ctrl-delete", DeleteWordRight, ctx),
213+
KeyBinding::new("ctrl-home", MoveToStart, ctx),
214+
KeyBinding::new("ctrl-end", MoveToEnd, ctx),
215+
KeyBinding::new("ctrl-shift-home", SelectToStart, ctx),
216+
KeyBinding::new("ctrl-shift-end", SelectToEnd, ctx),
217+
]
185218
}
186219

187220
// -- The focus gate for bare printable keys -----------------------------------
@@ -2380,6 +2413,164 @@ impl Element for TextBody {
23802413
mod tests {
23812414
use super::*;
23822415

2416+
// -- Key bindings ---------------------------------------------------------
2417+
2418+
fn single_keystroke(binding: &KeyBinding) -> &gpui::Keystroke {
2419+
assert_eq!(binding.keystrokes().len(), 1, "{binding:?}");
2420+
binding.keystrokes()[0].inner()
2421+
}
2422+
2423+
fn bound<'a>(bindings: &'a [KeyBinding], source: &str) -> Vec<&'a KeyBinding> {
2424+
let keystroke = gpui::Keystroke::parse(source).unwrap();
2425+
bindings
2426+
.iter()
2427+
.filter(|binding| single_keystroke(binding) == &keystroke)
2428+
.collect()
2429+
}
2430+
2431+
fn assert_bound(bindings: &[KeyBinding], source: &str, action: &dyn gpui::Action) {
2432+
let matches = bound(bindings, source);
2433+
assert_eq!(matches.len(), 1, "{source} should be bound exactly once");
2434+
assert!(
2435+
matches[0].action().partial_eq(action),
2436+
"{source} is bound to {}, expected {}",
2437+
matches[0].action().name(),
2438+
action.name()
2439+
);
2440+
}
2441+
2442+
const MAC_CHORDS: usize = 40;
2443+
2444+
#[test]
2445+
fn every_binding_is_a_single_keystroke_in_the_text_input_context() {
2446+
for binding in bindings() {
2447+
single_keystroke(&binding);
2448+
assert_eq!(
2449+
binding.predicate().map(|predicate| predicate.to_string()),
2450+
Some(KEY_CONTEXT.to_string()),
2451+
"{binding:?}"
2452+
);
2453+
}
2454+
}
2455+
2456+
#[test]
2457+
fn no_keystroke_is_bound_twice() {
2458+
let bindings = bindings();
2459+
let mut seen = std::collections::HashSet::new();
2460+
for binding in &bindings {
2461+
assert!(
2462+
seen.insert(single_keystroke(binding).unparse()),
2463+
"{binding:?} is bound twice"
2464+
);
2465+
}
2466+
}
2467+
2468+
#[test]
2469+
fn no_bare_printable_key_is_bound() {
2470+
for binding in bindings() {
2471+
let keystroke = single_keystroke(&binding);
2472+
let modified = keystroke.modifiers.control
2473+
|| keystroke.modifiers.alt
2474+
|| keystroke.modifiers.platform
2475+
|| keystroke.modifiers.function;
2476+
assert!(
2477+
modified || keystroke.key.chars().count() > 1,
2478+
"{binding:?} would swallow a typed character"
2479+
);
2480+
}
2481+
}
2482+
2483+
#[test]
2484+
fn mac_chords_are_unchanged() {
2485+
let golden: &[(&str, &dyn gpui::Action)] = &[
2486+
("backspace", &Backspace),
2487+
("delete", &DeleteForward),
2488+
("alt-backspace", &DeleteWordLeft),
2489+
("alt-delete", &DeleteWordRight),
2490+
("cmd-backspace", &DeleteToLineStart),
2491+
("cmd-delete", &DeleteToLineEnd),
2492+
("left", &MoveLeft),
2493+
("right", &MoveRight),
2494+
("up", &MoveUp),
2495+
("down", &MoveDown),
2496+
("alt-left", &MoveWordLeft),
2497+
("alt-right", &MoveWordRight),
2498+
("cmd-left", &MoveToLineStart),
2499+
("cmd-right", &MoveToLineEnd),
2500+
("home", &MoveToLineStart),
2501+
("end", &MoveToLineEnd),
2502+
("cmd-up", &MoveToStart),
2503+
("cmd-down", &MoveToEnd),
2504+
("shift-left", &SelectLeft),
2505+
("shift-right", &SelectRight),
2506+
("shift-up", &SelectUp),
2507+
("shift-down", &SelectDown),
2508+
("alt-shift-left", &SelectWordLeft),
2509+
("alt-shift-right", &SelectWordRight),
2510+
("cmd-shift-left", &SelectToLineStart),
2511+
("cmd-shift-right", &SelectToLineEnd),
2512+
("shift-home", &SelectToLineStart),
2513+
("shift-end", &SelectToLineEnd),
2514+
("cmd-shift-up", &SelectToStart),
2515+
("cmd-shift-down", &SelectToEnd),
2516+
("cmd-a", &SelectAll),
2517+
("cmd-c", &Copy),
2518+
("cmd-x", &Cut),
2519+
("cmd-v", &Paste),
2520+
("cmd-z", &Undo),
2521+
("cmd-shift-z", &Redo),
2522+
("cmd-y", &Redo),
2523+
("enter", &Confirm),
2524+
("escape", &Cancel),
2525+
("ctrl-cmd-space", &ShowCharacterPalette),
2526+
];
2527+
assert_eq!(golden.len(), MAC_CHORDS);
2528+
let bindings = bindings();
2529+
for (source, action) in golden {
2530+
assert_bound(&bindings, source, *action);
2531+
}
2532+
if cfg!(target_os = "macos") {
2533+
assert_eq!(bindings.len(), MAC_CHORDS);
2534+
}
2535+
}
2536+
2537+
#[test]
2538+
fn ctrl_chords_edit_text_only_off_macos() {
2539+
let chords: &[(&str, &dyn gpui::Action)] = &[
2540+
("ctrl-a", &SelectAll),
2541+
("ctrl-c", &Copy),
2542+
("ctrl-x", &Cut),
2543+
("ctrl-v", &Paste),
2544+
("ctrl-z", &Undo),
2545+
("ctrl-shift-z", &Redo),
2546+
("ctrl-y", &Redo),
2547+
("ctrl-left", &MoveWordLeft),
2548+
("ctrl-right", &MoveWordRight),
2549+
("ctrl-shift-left", &SelectWordLeft),
2550+
("ctrl-shift-right", &SelectWordRight),
2551+
("ctrl-backspace", &DeleteWordLeft),
2552+
("ctrl-delete", &DeleteWordRight),
2553+
("ctrl-home", &MoveToStart),
2554+
("ctrl-end", &MoveToEnd),
2555+
("ctrl-shift-home", &SelectToStart),
2556+
("ctrl-shift-end", &SelectToEnd),
2557+
];
2558+
let bindings = bindings();
2559+
for (source, action) in chords {
2560+
if cfg!(target_os = "macos") {
2561+
assert!(
2562+
bound(&bindings, source).is_empty(),
2563+
"{source} is bound on macOS"
2564+
);
2565+
} else {
2566+
assert_bound(&bindings, source, *action);
2567+
}
2568+
}
2569+
if !cfg!(target_os = "macos") {
2570+
assert_eq!(bindings.len(), MAC_CHORDS + chords.len());
2571+
}
2572+
}
2573+
23832574
// -- Word navigation ------------------------------------------------------
23842575

23852576
/// `alt-left` skips the run of separators first, then the word, which is

0 commit comments

Comments
 (0)