Merge pull request #581 from peco/fix-ctrl-shift-modifiers

Fix #529
This commit is contained in:
lestrrat 2026-02-15 08:48:57 +09:00 committed by GitHub
commit 4ed16af465
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 200 additions and 27 deletions

View file

@ -411,6 +411,21 @@ As a similar example, a common idiom in emacs is that `C-c C-c` means "take the
Since v0.1.8, in addition to values below, you may put a `M-` prefix on any
key item to use Alt/Option key as a mask.
You can also use `C-` and `S-` prefixes on navigation keys to bind Ctrl and Shift modified keys. Multiple modifiers can be combined. For example:
```json
{
"Keymap": {
"C-ArrowLeft": "peco.BackwardWord",
"C-ArrowRight": "peco.ForwardWord",
"S-ArrowUp": "peco.SelectUp",
"C-M-Delete": "peco.DeleteForwardWord"
}
}
```
Note: `C-` on single characters (e.g. `C-a`) refers to ASCII control codes as before. `C-` as a modifier applies to navigation keys such as `ArrowLeft`, `Home`, `Delete`, etc.
| Name | Notes |
|-------------|-------|
| C-a ... C-z | Control + whatever character |

View file

@ -239,8 +239,8 @@ func KeyEventToString(key KeyType, ch rune, mod ModifierKey) (string, error) {
}
}
if mod == ModAlt {
return "M-" + s, nil
if m := mod.String(); m != "" {
return m + "-" + s, nil
}
return s, nil
@ -248,25 +248,48 @@ func KeyEventToString(key KeyType, ch rune, mod ModifierKey) (string, error) {
func ToKey(key string) (k KeyType, modifier ModifierKey, ch rune, err error) {
modifier = ModNone
if strings.HasPrefix(key, "M-") {
modifier = ModAlt
key = key[2:]
// Try full string first. This handles legacy key names like "C-a",
// "C-v", "Home", "ArrowLeft", etc. that are registered in stringToKey.
if k, ok := stringToKey[key]; ok {
return k, modifier, 0, nil
}
// Parse modifier prefixes (C-, S-, M-) iteratively.
// After each prefix is stripped, try the remainder as a key name.
for {
switch {
case strings.HasPrefix(key, "C-"):
modifier |= ModCtrl
key = key[2:]
case strings.HasPrefix(key, "S-"):
modifier |= ModShift
key = key[2:]
case strings.HasPrefix(key, "M-"):
modifier |= ModAlt
key = key[2:]
default:
goto done
}
// After stripping a prefix, try as a registered key name.
// This handles e.g. "M-C-v" → strip M-, then "C-v" is found.
if k, ok := stringToKey[key]; ok {
return k, modifier, 0, nil
}
// Single ASCII char after modifier(s) → treat as rune
if len(key) == 1 {
ch = rune(key[0])
return
return 0, modifier, rune(key[0]), nil
}
}
var ok bool
k, ok = stringToKey[key]
if !ok {
// If this is a single rune, just allow it
ch, _ = utf8.DecodeRuneInString(key)
if ch != utf8.RuneError {
return
}
err = errors.Errorf("no such key %s", key)
done:
// Try as a single rune (handles multi-byte chars like "せ")
ch, _ = utf8.DecodeRuneInString(key)
if ch != utf8.RuneError {
return 0, modifier, ch, nil
}
return
return 0, modifier, 0, errors.Errorf("no such key %s", key)
}

View file

@ -3,6 +3,8 @@ package keyseq
import (
"testing"
"unicode/utf8"
"github.com/stretchr/testify/require"
)
func TestKeymapStrToKeyValue(t *testing.T) {
@ -152,3 +154,124 @@ func TestKeymapStrToKeyValueCh(t *testing.T) {
}
}
func TestKeymapStrToKeyValueWithCtrl(t *testing.T) {
tests := []struct {
name string
key KeyType
}{
{"C-ArrowLeft", KeyArrowLeft},
{"C-ArrowRight", KeyArrowRight},
{"C-ArrowUp", KeyArrowUp},
{"C-ArrowDown", KeyArrowDown},
{"C-Home", KeyHome},
{"C-End", KeyEnd},
{"C-Delete", KeyDelete},
{"C-Insert", KeyInsert},
{"C-Pgup", KeyPgup},
{"C-Pgdn", KeyPgdn},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
k, modifier, ch, err := ToKey(tc.name)
require.NoError(t, err)
require.Equal(t, tc.key, k)
require.Equal(t, ModCtrl, modifier)
require.Equal(t, rune(0), ch)
})
}
}
func TestKeymapStrToKeyValueWithShift(t *testing.T) {
tests := []struct {
name string
key KeyType
}{
{"S-ArrowUp", KeyArrowUp},
{"S-ArrowDown", KeyArrowDown},
{"S-ArrowLeft", KeyArrowLeft},
{"S-ArrowRight", KeyArrowRight},
{"S-Home", KeyHome},
{"S-End", KeyEnd},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
k, modifier, ch, err := ToKey(tc.name)
require.NoError(t, err)
require.Equal(t, tc.key, k)
require.Equal(t, ModShift, modifier)
require.Equal(t, rune(0), ch)
})
}
}
func TestKeymapStrToKeyValueWithCombinedModifiers(t *testing.T) {
tests := []struct {
name string
key KeyType
modifier ModifierKey
}{
{"C-M-ArrowLeft", KeyArrowLeft, ModCtrl | ModAlt},
{"M-C-ArrowLeft", KeyArrowLeft, ModCtrl | ModAlt},
{"C-S-Delete", KeyDelete, ModCtrl | ModShift},
{"C-S-M-Home", KeyHome, ModCtrl | ModShift | ModAlt},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
k, modifier, ch, err := ToKey(tc.name)
require.NoError(t, err)
require.Equal(t, tc.key, k)
require.Equal(t, tc.modifier, modifier)
require.Equal(t, rune(0), ch)
})
}
}
func TestModifierKeyString(t *testing.T) {
tests := []struct {
mod ModifierKey
expected string
}{
{ModNone, ""},
{ModAlt, "M"},
{ModCtrl, "C"},
{ModShift, "S"},
{ModCtrl | ModAlt, "C-M"},
{ModCtrl | ModShift, "C-S"},
{ModShift | ModAlt, "S-M"},
{ModCtrl | ModShift | ModAlt, "C-S-M"},
}
for _, tc := range tests {
t.Run(tc.expected, func(t *testing.T) {
require.Equal(t, tc.expected, tc.mod.String())
})
}
}
func TestKeyEventToStringWithModifiers(t *testing.T) {
tests := []struct {
name string
key KeyType
ch rune
mod ModifierKey
expected string
}{
{"Ctrl+Left", KeyArrowLeft, 0, ModCtrl, "C-<"},
{"Shift+Right", KeyArrowRight, 0, ModShift, "S->"},
{"Ctrl+Alt+Delete", KeyDelete, 0, ModCtrl | ModAlt, "C-M-Delete"},
{"Alt+char", 0, 'v', ModAlt, "M-v"},
{"no modifier", KeyHome, 0, ModNone, "Home"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s, err := KeyEventToString(tc.key, tc.ch, tc.mod)
require.NoError(t, err)
require.Equal(t, tc.expected, s)
})
}
}

View file

@ -14,9 +14,10 @@ var ErrNoMatch = errors.New("could not match key to any action")
type ModifierKey int
const (
ModNone ModifierKey = iota
ModAlt
ModMax
ModNone ModifierKey = 0
ModAlt ModifierKey = 1 << 0 // 0x01
ModCtrl ModifierKey = 1 << 1 // 0x02
ModShift ModifierKey = 1 << 2 // 0x04
)
// Key is data in one trie node in the KeySequence
@ -35,12 +36,17 @@ func (kl KeyList) String() string {
}
func (m ModifierKey) String() string {
switch m {
case ModAlt:
return "M"
default:
return ""
var parts []string
if m&ModCtrl != 0 {
parts = append(parts, "C")
}
if m&ModShift != 0 {
parts = append(parts, "S")
}
if m&ModAlt != 0 {
parts = append(parts, "M")
}
return strings.Join(parts, "-")
}
func (k Key) String() string {

View file

@ -57,8 +57,14 @@ func tcellEventToEvent(tev tcell.Event) Event {
switch ev := tev.(type) {
case *tcell.EventKey:
var mod keyseq.ModifierKey
if ev.Modifiers()&tcell.ModCtrl != 0 {
mod |= keyseq.ModCtrl
}
if ev.Modifiers()&tcell.ModShift != 0 {
mod |= keyseq.ModShift
}
if ev.Modifiers()&tcell.ModAlt != 0 {
mod = keyseq.ModAlt
mod |= keyseq.ModAlt
}
key := ev.Key()