fix: Ctrl+Space (ToggleSelection) not working after tcell migration

tcellEventToEvent did not convert Ctrl+Space to KeyCtrlSpace for
CSI u terminals or traditional terminals (tcell emits KeyCtrlSpace
which fell outside the KeyCtrlA..KeyCtrlZ range).
This commit is contained in:
Masayoshi Wada 2026-03-01 16:58:51 +09:00
parent 1eab243c5e
commit 42c39e38c8
2 changed files with 74 additions and 6 deletions

View file

@ -110,8 +110,20 @@ func tcellEventToEvent(tev tcell.Event) Event {
}
}
// Special case: space must be sent as KeySpace with Ch=0
// to match the convention expected by doAcceptChar
// Ctrl+Space via CSI u: tcell delivers KeyRune with
// rune=' ' and ModCtrl. Convert to KeyCtrlSpace (0x00).
if r == ' ' && mod&keyseq.ModCtrl != 0 {
mod &^= keyseq.ModCtrl
return Event{
Type: EventKey,
Key: keyseq.KeyCtrlSpace,
Ch: 0,
Mod: mod,
}
}
// Plain space must be sent as KeySpace with Ch=0
// to match the convention expected by doAcceptChar.
if r == ' ' {
return Event{
Type: EventKey,
@ -138,17 +150,17 @@ func tcellEventToEvent(tev tcell.Event) Event {
}
}
// Ctrl+letter keys: tcell.KeyCtrlA(65)..KeyCtrlZ(90).
// Ctrl keys: tcell.KeyCtrlSpace(64)..KeyCtrlZ(90).
// On terminals with enhanced keyboard protocols (CSI u /
// fixterms), tcell normalizes Ctrl+letter to these constants
// with ModCtrl set. Peco's keyseq system encodes the ctrl
// nature in the key value (0x01-0x1A), not in the modifier,
// nature in the key value (0x00-0x1A), not in the modifier,
// so strip the redundant ModCtrl. (issue #715)
if key >= tcell.KeyCtrlA && key <= tcell.KeyCtrlZ {
if key >= tcell.KeyCtrlSpace && key <= tcell.KeyCtrlZ {
mod &^= keyseq.ModCtrl
return Event{
Type: EventKey,
Key: keyseq.KeyType(key - tcell.KeyCtrlA + 1),
Key: keyseq.KeyType(key - tcell.KeyCtrlSpace),
Ch: 0,
Mod: mod,
}

View file

@ -538,6 +538,62 @@ func TestTcellEventToEventCtrlKeysStripModCtrl(t *testing.T) {
}
}
// TestTcellEventToEventCtrlSpace verifies that Ctrl+Space is correctly
// converted to KeyCtrlSpace (0x00) regardless of how the terminal reports it.
//
// Traditional terminals send NUL (0x00); tcell's input handler
// delivers this as KeyCtrlSpace(64) with ModCtrl.
// The KeyCtrlSpace..KeyCtrlZ path handles this.
//
// CSI u / enhanced terminals report Ctrl+Space as KeyRune with rune=' '
// and ModCtrl. This must also produce Key=KeyCtrlSpace, Mod=ModNone
// so that the ToggleSelectionAndSelectNext action fires correctly.
func TestTcellEventToEventCtrlSpace(t *testing.T) {
t.Parallel()
tests := []struct {
name string
tcellEv *tcell.EventKey
wantKey keyseq.KeyType
wantCh rune
wantMod keyseq.ModifierKey
}{
{
// Traditional terminal: NUL byte (0x00).
// tcell's input handler calls
// NewEventKey(KeyCtrlSpace+Key(r), 0, ModCtrl)
// which yields key=KeyCtrlSpace(64), mod=ModCtrl.
// The KeyCtrlSpace..KeyCtrlZ path strips ModCtrl and
// maps to keyseq.KeyCtrlSpace(0x00).
name: "traditional terminal: NUL byte via KeyCtrlSpace+ModCtrl",
tcellEv: tcell.NewEventKey(tcell.KeyCtrlSpace, 0, tcell.ModCtrl),
wantKey: keyseq.KeyCtrlSpace,
wantCh: 0,
wantMod: keyseq.ModNone,
},
{
// CSI u / enhanced terminal: Ctrl+Space reported as
// KeyRune with rune=' ' and ModCtrl.
name: "CSI u terminal: Ctrl+Space as rune with ModCtrl",
tcellEv: tcell.NewEventKey(tcell.KeyRune, ' ', tcell.ModCtrl),
wantKey: keyseq.KeyCtrlSpace,
wantCh: 0,
wantMod: keyseq.ModNone,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := tcellEventToEvent(tt.tcellEv)
require.Equal(t, EventKey, got.Type, "event type")
require.Equal(t, tt.wantKey, got.Key, "key")
require.Equal(t, tt.wantCh, got.Ch, "ch")
require.Equal(t, tt.wantMod, got.Mod, "modifier")
})
}
}
// TestTcellEventToEventCtrlWithAltPreservesAlt verifies that Ctrl+Alt
// combinations strip ModCtrl but preserve ModAlt.
func TestTcellEventToEventCtrlWithAltPreservesAlt(t *testing.T) {