@@ -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.
141141pub 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 {
23802413mod 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