Files
Stefan Haller 5748d82073 Convert keybinding fields to Keybinding
Until now every keybinding config field was a plain string. That meant a user
couldn't ask for two keys to invoke a command — the config silently accepted
only one form.

Convert every string-typed field across all 13 KeybindingXxxConfig structs to
Keybinding so the union type extends to every command. Defaults wrap their
single-key value in Keybinding{...} so the generated Config.md still renders one
scalar key per binding.

The alt fields keep their separate Binding registrations for now: this commit
does not yet introduce the merge mechanism that folds them into the main field —
that comes in a follow-up. Consumers previously calling opts.GetKeys on a string
field now call opts.GetKeys on the Keybinding, or take .String() / Keys[0] where
a single value is needed.

Adds a Keybinding.String helper for rendering, schema-generator work that
inlines the Keybinding union into each consuming property, and a unit test
covering the user-facing scalar/sequence YAML forms for quit.
2026-05-25 15:32:47 +02:00

215 lines
5.2 KiB
Go

package config
import (
"log"
"strings"
"unicode/utf8"
"github.com/gdamore/tcell/v3"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/samber/lo"
)
// NOTE: if you make changes to this table, be sure to update
// docs/keybindings/Custom_Keybindings.md as well
var labelByKey = map[gocui.KeyName]string{
gocui.KeyF1: "f1",
gocui.KeyF2: "f2",
gocui.KeyF3: "f3",
gocui.KeyF4: "f4",
gocui.KeyF5: "f5",
gocui.KeyF6: "f6",
gocui.KeyF7: "f7",
gocui.KeyF8: "f8",
gocui.KeyF9: "f9",
gocui.KeyF10: "f10",
gocui.KeyF11: "f11",
gocui.KeyF12: "f12",
gocui.KeyInsert: "insert",
gocui.KeyDelete: "delete",
gocui.KeyHome: "home",
gocui.KeyEnd: "end",
gocui.KeyPgup: "pgup",
gocui.KeyPgdn: "pgdown",
gocui.KeyArrowUp: "up",
gocui.KeyArrowDown: "down",
gocui.KeyArrowLeft: "left",
gocui.KeyArrowRight: "right",
gocui.KeyTab: "tab",
gocui.KeyBacktab: "backtab",
gocui.KeyEnter: "enter",
gocui.KeyEsc: "esc",
gocui.KeyBackspace: "backspace",
gocui.MouseWheelUp: "mouse wheel up",
gocui.MouseWheelDown: "mouse wheel down",
}
var keyByLabel = lo.Invert(labelByKey)
func LabelForKey(key gocui.Key) string {
if !key.IsSet() {
return ""
}
label := ""
if key.Mod()&gocui.ModCtrl != 0 {
label += "ctrl+"
}
if key.Mod()&gocui.ModAlt != 0 {
label += "alt+"
}
if key.Mod()&gocui.ModShift != 0 {
label += "shift+"
}
if key.Mod()&gocui.ModMeta != 0 {
label += "meta+"
}
if key.KeyName() == gocui.KeyName(tcell.KeyRune) {
if key.Str() == " " {
label += "space"
} else if key.Str() == "-" && key.Mod() != gocui.ModNone {
label += "minus"
} else if key.Str() == "+" && key.Mod() != gocui.ModNone {
label += "plus"
} else {
label += key.Str()
}
} else {
value, ok := labelByKey[key.KeyName()]
if ok {
label += value
} else {
label += "unknown"
}
}
if utf8.RuneCountInString(label) > 1 {
label = "<" + label + ">"
}
return label
}
func KeyFromLabel(label string) (gocui.Key, bool) {
if label == "" || label == "<disabled>" {
return gocui.Key{}, true
}
if strings.HasPrefix(label, "<") && strings.HasSuffix(label, ">") {
label = label[1 : len(label)-1]
}
mod := gocui.ModNone
for {
// A bare "-" or "+" with any (or no) modifiers is a literal rune
// key; this also covers lenient forms like `<c-->` and `<c++>`,
// neither of which we emit (we use `<c-minus>` and `<c-+>`).
if label == "-" || label == "+" {
return gocui.NewKeyStrMod(label, mod), true
}
sepIdx := strings.IndexAny(label, "-+")
if sepIdx == -1 {
break
}
modStr, remainder := label[:sepIdx], label[sepIdx+1:]
label = remainder
switch modStr {
case "s", "shift":
if (mod & gocui.ModShift) != 0 {
return gocui.Key{}, false
}
mod |= gocui.ModShift
case "c", "ctrl":
if (mod & gocui.ModCtrl) != 0 {
return gocui.Key{}, false
}
mod |= gocui.ModCtrl
case "a", "alt":
if (mod & gocui.ModAlt) != 0 {
return gocui.Key{}, false
}
mod |= gocui.ModAlt
case "m", "meta":
if (mod & gocui.ModMeta) != 0 {
return gocui.Key{}, false
}
mod |= gocui.ModMeta
default:
return gocui.Key{}, false
}
}
if label == "space" {
return gocui.NewKeyStrMod(" ", mod), true
}
if label == "minus" {
if mod == gocui.ModShift {
return gocui.Key{}, false
}
return gocui.NewKeyStrMod("-", mod), true
}
if label == "plus" {
if mod == gocui.ModShift {
return gocui.Key{}, false
}
return gocui.NewKeyStrMod("+", mod), true
}
if keyName, ok := keyByLabel[label]; ok {
return gocui.NewKey(keyName, "", mod), true
}
runeCount := utf8.RuneCountInString(label)
if runeCount != 1 {
return gocui.Key{}, false
}
// Shift on a bare rune is invalid: terminals fold shift into the rune
// itself (shift+a arrives as "A"), so the binding could never fire.
// Space is exempt and handled above; combined with other modifiers,
// shift is fine because the terminal can't fold it into the rune then.
if mod == gocui.ModShift {
return gocui.Key{}, false
}
// An ASCII uppercase letter with any modifier is invalid. Ctrl+letter
// events always arrive with a lowercase rune — control codes have no
// case distinction (the terminal sends the same byte for ctrl+a and
// ctrl+A), and CSI-u protocols report the unshifted codepoint with
// shift as a separate modifier (alt+shift+a → rune='a' mod=Alt|Shift).
// Users should write <c-s-a> rather than <c-A>.
if mod != gocui.ModNone && len(label) == 1 && label[0] >= 'A' && label[0] <= 'Z' {
return gocui.Key{}, false
}
return gocui.NewKeyStrMod(label, mod), true
}
func isValidKeybindingKey(key string) bool {
_, ok := KeyFromLabel(key)
return ok
}
func GetValidatedKeyBindingKey(label string) gocui.Key {
key, ok := KeyFromLabel(label)
if !ok {
log.Fatalf("Unrecognized key %s, this should have been caught by user config validation", label)
}
return key
}
func GetValidatedKeyBindingKeys(labels Keybinding) []gocui.Key {
return lo.FilterMap(labels, func(label string, _ int) (gocui.Key, bool) {
k := GetValidatedKeyBindingKey(label)
return k, k.IsSet()
})
}