From a5b760f8d1c252d8ed9815c65f8dc398a6be9675 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Apr 2026 15:55:39 +0200 Subject: [PATCH 01/10] Fix typo in keybinding An uppercase letter is not valid with ctrl, and only works because we lowercase the string before parsing it. This will change later in this branch when we start supporting bindings like . --- docs-master/Config.md | 2 +- pkg/config/user_config.go | 2 +- schema-master/config.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs-master/Config.md b/docs-master/Config.md index 9931fda61..265ba2311 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -746,7 +746,7 @@ keybinding: markCommitAsBaseForRebase: B tagCommit: T checkoutCommit: - resetCherryPick: + resetCherryPick: copyCommitAttributeToClipboard: "y" openLogMenu: openInBrowser: o diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 29778d639..2106e2ad2 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -1044,7 +1044,7 @@ func GetDefaultConfig() *UserConfig { MarkCommitAsBaseForRebase: "B", CreateTag: "T", CheckoutCommit: "", - ResetCherryPick: "", + ResetCherryPick: "", CopyCommitAttributeToClipboard: "y", OpenLogMenu: "", OpenInBrowser: "o", diff --git a/schema-master/config.json b/schema-master/config.json index c4312981f..590a1a9b9 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -1040,7 +1040,7 @@ }, "resetCherryPick": { "type": "string", - "default": "\u003cc-R\u003e" + "default": "\u003cc-r\u003e" }, "copyCommitAttributeToClipboard": { "type": "string", From c455fca0623e47bfb28ebda04a0ab7a9903c8485 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Apr 2026 09:58:07 +0200 Subject: [PATCH 02/10] Add config.KeyFromLabel --- pkg/config/keynames.go | 22 +++++++++++++++------- pkg/gui/keybindings/keybindings.go | 25 +++++-------------------- 2 files changed, 20 insertions(+), 27 deletions(-) diff --git a/pkg/config/keynames.go b/pkg/config/keynames.go index 075e8006e..2f88908ec 100644 --- a/pkg/config/keynames.go +++ b/pkg/config/keynames.go @@ -73,16 +73,24 @@ var LabelByKey = map[gocui.KeyName]string{ var KeyByLabel = lo.Invert(LabelByKey) -func isValidKeybindingKey(key string) bool { - runeCount := utf8.RuneCountInString(key) - if key == "" { - return true +func KeyFromLabel(label string) (gocui.Key, bool) { + if label == "" || label == "" { + return gocui.Key{}, true } + runeCount := utf8.RuneCountInString(label) if runeCount > 1 { - _, ok := KeyByLabel[strings.ToLower(key)] - return ok + keyName, ok := KeyByLabel[strings.ToLower(label)] + if !ok { + return gocui.Key{}, false + } + return gocui.NewKeyName(keyName), true } - return true + return gocui.NewKeyRune([]rune(label)[0]), true +} + +func isValidKeybindingKey(key string) bool { + _, ok := KeyFromLabel(key) + return ok } diff --git a/pkg/gui/keybindings/keybindings.go b/pkg/gui/keybindings/keybindings.go index 4b67e23fe..9ed420f3b 100644 --- a/pkg/gui/keybindings/keybindings.go +++ b/pkg/gui/keybindings/keybindings.go @@ -2,12 +2,9 @@ package keybindings import ( "log" - "strings" - "unicode/utf8" "github.com/gdamore/tcell/v3" "github.com/jesseduffield/lazygit/pkg/config" - "github.com/jesseduffield/lazygit/pkg/constants" "github.com/jesseduffield/lazygit/pkg/gocui" ) @@ -28,23 +25,11 @@ func LabelFromKey(key gocui.Key) string { return "unknown" } -func GetKey(key string) gocui.Key { - if key == "" { - return gocui.Key{} +func GetKey(label string) gocui.Key { + key, ok := config.KeyFromLabel(label) + if !ok { + log.Fatalf("Unrecognized key %s, this should have been caught by user config validation", label) } - runeCount := utf8.RuneCountInString(key) - if runeCount > 1 { - keyName, ok := config.KeyByLabel[strings.ToLower(key)] - if !ok { - log.Fatalf("Unrecognized key %s for keybinding. For permitted values see %s", strings.ToLower(key), constants.Links.Docs.CustomKeybindings) - } - return gocui.NewKeyName(keyName) - } - - if runeCount == 1 { - return gocui.NewKeyRune([]rune(key)[0]) - } - - return gocui.Key{} + return key } From 69e99ffd64719c7452715eddc96c493bf6c8668d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Apr 2026 10:09:41 +0200 Subject: [PATCH 03/10] Move keybindings.LabelFromKey to config package So that it is next to KeyFromLabel. --- pkg/cheatsheet/generate.go | 5 ++--- pkg/config/keynames.go | 18 ++++++++++++++++++ pkg/gui/context/menu_context.go | 6 +++--- pkg/gui/keybindings/keybindings.go | 18 ------------------ pkg/gui/options_map.go | 4 ++-- 5 files changed, 25 insertions(+), 26 deletions(-) diff --git a/pkg/cheatsheet/generate.go b/pkg/cheatsheet/generate.go index 36cbf3786..f2f42b13c 100644 --- a/pkg/cheatsheet/generate.go +++ b/pkg/cheatsheet/generate.go @@ -22,7 +22,6 @@ import ( "github.com/jesseduffield/lazycore/pkg/utils" "github.com/jesseduffield/lazygit/pkg/app" "github.com/jesseduffield/lazygit/pkg/config" - "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/samber/lo" @@ -157,7 +156,7 @@ func getBindingSections(bindings []*types.Binding, tr *i18n.TranslationSet) []*b bindingsByHeader, func(header header, hBindings []*types.Binding) headerWithBindings { uniqBindings := lo.UniqBy(hBindings, func(binding *types.Binding) string { - return binding.Description + keybindings.LabelFromKey(binding.Key) + return binding.Description + config.LabelForKey(binding.Key) }) return headerWithBindings{ @@ -217,7 +216,7 @@ func formatTitle(title string) string { } func formatBinding(binding *types.Binding) string { - action := keybindings.LabelFromKey(binding.Key) + action := config.LabelForKey(binding.Key) description := binding.Description if binding.Alternative != "" { action += fmt.Sprintf(" (%s)", binding.Alternative) diff --git a/pkg/config/keynames.go b/pkg/config/keynames.go index 2f88908ec..1f76ab7b4 100644 --- a/pkg/config/keynames.go +++ b/pkg/config/keynames.go @@ -4,6 +4,7 @@ import ( "strings" "unicode/utf8" + "github.com/gdamore/tcell/v3" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/samber/lo" ) @@ -73,6 +74,23 @@ var LabelByKey = map[gocui.KeyName]string{ var KeyByLabel = lo.Invert(LabelByKey) +func LabelForKey(key gocui.Key) string { + if !key.IsSet() { + return "" + } + + if key.KeyName() == gocui.KeyName(tcell.KeyRune) { + return key.Str() + } + + value, ok := LabelByKey[key.KeyName()] + if ok { + return value + } + + return "unknown" +} + func KeyFromLabel(label string) (gocui.Key, bool) { if label == "" || label == "" { return gocui.Key{}, true diff --git a/pkg/gui/context/menu_context.go b/pkg/gui/context/menu_context.go index e9b04061b..e9fece612 100644 --- a/pkg/gui/context/menu_context.go +++ b/pkg/gui/context/menu_context.go @@ -4,7 +4,7 @@ import ( "errors" "strings" - "github.com/jesseduffield/lazygit/pkg/gui/keybindings" + "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/i18n" @@ -73,7 +73,7 @@ func NewMenuViewModel(c *ContextCommon) *MenuViewModel { func() []*types.MenuItem { return self.menuItems }, func(item *types.MenuItem) []string { if filterKeybindings { - return []string{keybindings.LabelFromKey(item.Key)} + return []string{config.LabelForKey(item.Key)} } return item.LabelColumns @@ -139,7 +139,7 @@ func (self *MenuViewModel) GetDisplayStrings(_ int, _ int) [][]string { keyLabel := "" if item.Key.IsSet() { - keyLabel = style.FgCyan.Sprint(keybindings.LabelFromKey(item.Key)) + keyLabel = style.FgCyan.Sprint(config.LabelForKey(item.Key)) } checkMark := "" diff --git a/pkg/gui/keybindings/keybindings.go b/pkg/gui/keybindings/keybindings.go index 9ed420f3b..977e00f33 100644 --- a/pkg/gui/keybindings/keybindings.go +++ b/pkg/gui/keybindings/keybindings.go @@ -3,28 +3,10 @@ package keybindings import ( "log" - "github.com/gdamore/tcell/v3" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" ) -func LabelFromKey(key gocui.Key) string { - if !key.IsSet() { - return "" - } - - if key.KeyName() == gocui.KeyName(tcell.KeyRune) { - return key.Str() - } - - value, ok := config.LabelByKey[key.KeyName()] - if ok { - return value - } - - return "unknown" -} - func GetKey(label string) gocui.Key { key, ok := config.KeyFromLabel(label) if !ok { diff --git a/pkg/gui/options_map.go b/pkg/gui/options_map.go index f43b63a27..c6360e27c 100644 --- a/pkg/gui/options_map.go +++ b/pkg/gui/options_map.go @@ -5,10 +5,10 @@ import ( "strings" "github.com/jesseduffield/generics/set" + "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" - "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/theme" @@ -60,7 +60,7 @@ func (self *OptionsMapMgr) renderContextOptionsMap() { } return bindingInfo{ - key: keybindings.LabelFromKey(binding.Key), + key: config.LabelForKey(binding.Key), description: binding.GetShortDescription(), style: displayStyle, } From c41fae9fc4f3703739030cdc959b1d4774e874b2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Apr 2026 10:10:31 +0200 Subject: [PATCH 04/10] Unexport the labelByKey and keyByLabel maps --- pkg/config/keynames.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/config/keynames.go b/pkg/config/keynames.go index 1f76ab7b4..f96fa68ba 100644 --- a/pkg/config/keynames.go +++ b/pkg/config/keynames.go @@ -12,7 +12,7 @@ import ( // 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{ +var labelByKey = map[gocui.KeyName]string{ gocui.KeyF1: "", gocui.KeyF2: "", gocui.KeyF3: "", @@ -72,7 +72,7 @@ var LabelByKey = map[gocui.KeyName]string{ gocui.MouseWheelDown: "mouse wheel down", } -var KeyByLabel = lo.Invert(LabelByKey) +var keyByLabel = lo.Invert(labelByKey) func LabelForKey(key gocui.Key) string { if !key.IsSet() { @@ -83,7 +83,7 @@ func LabelForKey(key gocui.Key) string { return key.Str() } - value, ok := LabelByKey[key.KeyName()] + value, ok := labelByKey[key.KeyName()] if ok { return value } @@ -98,7 +98,7 @@ func KeyFromLabel(label string) (gocui.Key, bool) { runeCount := utf8.RuneCountInString(label) if runeCount > 1 { - keyName, ok := KeyByLabel[strings.ToLower(label)] + keyName, ok := keyByLabel[strings.ToLower(label)] if !ok { return gocui.Key{}, false } From 30b619b3db76e7fe095e267f5b1f6ed655a41309 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Apr 2026 10:21:30 +0200 Subject: [PATCH 05/10] Get rid of pkg/gui/keybindings package Move keybindings.GetKey to config.GetValidatedKeyBindingKey --- pkg/config/keynames.go | 10 ++++++++++ pkg/gui/gui.go | 7 +++---- pkg/gui/gui_driver.go | 6 ++++-- pkg/gui/keybindings.go | 8 ++++---- pkg/gui/keybindings/keybindings.go | 17 ----------------- pkg/gui/menu_panel.go | 10 +++++----- pkg/gui/services/custom_commands/client.go | 7 +++---- .../services/custom_commands/handler_creator.go | 3 +-- .../custom_commands/keybinding_creator.go | 3 +-- 9 files changed, 31 insertions(+), 40 deletions(-) delete mode 100644 pkg/gui/keybindings/keybindings.go diff --git a/pkg/config/keynames.go b/pkg/config/keynames.go index f96fa68ba..a6fe9e74e 100644 --- a/pkg/config/keynames.go +++ b/pkg/config/keynames.go @@ -1,6 +1,7 @@ package config import ( + "log" "strings" "unicode/utf8" @@ -112,3 +113,12 @@ 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 +} diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index d74c8e2a4..cf024a728 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -26,7 +26,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" - "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/modes/cherrypicking" "github.com/jesseduffield/lazygit/pkg/gui/modes/diffing" "github.com/jesseduffield/lazygit/pkg/gui/modes/filtering" @@ -471,9 +470,9 @@ func (gui *Gui) onUserConfigLoaded() error { gui.setColorScheme() gui.configureViewProperties() - gui.g.SearchEscapeKey = keybindings.GetKey(userConfig.Keybinding.Universal.Return) - gui.g.NextSearchMatchKey = keybindings.GetKey(userConfig.Keybinding.Universal.NextMatch) - gui.g.PrevSearchMatchKey = keybindings.GetKey(userConfig.Keybinding.Universal.PrevMatch) + gui.g.SearchEscapeKey = config.GetValidatedKeyBindingKey(userConfig.Keybinding.Universal.Return) + gui.g.NextSearchMatchKey = config.GetValidatedKeyBindingKey(userConfig.Keybinding.Universal.NextMatch) + gui.g.PrevSearchMatchKey = config.GetValidatedKeyBindingKey(userConfig.Keybinding.Universal.PrevMatch) gui.g.ShowListFooter = userConfig.Gui.ShowListFooter diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 18f59152b..f70e8c9b3 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -10,7 +10,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" - "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/types" integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types" ) @@ -29,7 +28,10 @@ var _ integrationTypes.GuiDriver = &GuiDriver{} func (self *GuiDriver) PressKey(keyStr string) { self.CheckAllToastsAcknowledged() - key := keybindings.GetKey(keyStr) + key, ok := config.KeyFromLabel(keyStr) + if !ok { + self.Fail("Unrecognized key: " + keyStr) + } self.gui.g.ReplayedEvents.Keys <- gocui.NewTcellKeyEventWrapper( tcell.NewEventKey(tcell.Key(key.KeyName()), key.Str(), tcell.ModNone), diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index 7780f74b0..74e68d818 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -4,10 +4,10 @@ import ( "errors" "log" + "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" - "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -61,7 +61,7 @@ func (gui *Gui) GetCheatsheetKeybindings() []*types.Binding { } func (gui *Gui) keybindingOpts() types.KeybindingsOpts { - config := gui.c.UserConfig().Keybinding + keybindingConfig := gui.c.UserConfig().Keybinding guards := types.KeybindingGuards{ OutsideFilterMode: gui.outsideFilterMode, @@ -69,8 +69,8 @@ func (gui *Gui) keybindingOpts() types.KeybindingsOpts { } return types.KeybindingsOpts{ - GetKey: keybindings.GetKey, - Config: config, + GetKey: config.GetValidatedKeyBindingKey, + Config: keybindingConfig, Guards: guards, } } diff --git a/pkg/gui/keybindings/keybindings.go b/pkg/gui/keybindings/keybindings.go deleted file mode 100644 index 977e00f33..000000000 --- a/pkg/gui/keybindings/keybindings.go +++ /dev/null @@ -1,17 +0,0 @@ -package keybindings - -import ( - "log" - - "github.com/jesseduffield/lazygit/pkg/config" - "github.com/jesseduffield/lazygit/pkg/gocui" -) - -func GetKey(label string) gocui.Key { - key, ok := config.KeyFromLabel(label) - if !ok { - log.Fatalf("Unrecognized key %s, this should have been caught by user config validation", label) - } - - return key -} diff --git a/pkg/gui/menu_panel.go b/pkg/gui/menu_panel.go index e410f934f..248da40fb 100644 --- a/pkg/gui/menu_panel.go +++ b/pkg/gui/menu_panel.go @@ -3,8 +3,8 @@ package gui import ( "fmt" + "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" - "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/samber/lo" @@ -28,10 +28,10 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error { maxColumnSize := 1 essentialKeys := []gocui.Key{ - keybindings.GetKey(gui.c.UserConfig().Keybinding.Universal.ConfirmMenu), - keybindings.GetKey(gui.c.UserConfig().Keybinding.Universal.Return), - keybindings.GetKey(gui.c.UserConfig().Keybinding.Universal.PrevItem), - keybindings.GetKey(gui.c.UserConfig().Keybinding.Universal.NextItem), + config.GetValidatedKeyBindingKey(gui.c.UserConfig().Keybinding.Universal.ConfirmMenu), + config.GetValidatedKeyBindingKey(gui.c.UserConfig().Keybinding.Universal.Return), + config.GetValidatedKeyBindingKey(gui.c.UserConfig().Keybinding.Universal.PrevItem), + config.GetValidatedKeyBindingKey(gui.c.UserConfig().Keybinding.Universal.NextItem), } for _, item := range opts.Items { diff --git a/pkg/gui/services/custom_commands/client.go b/pkg/gui/services/custom_commands/client.go index aceaef2dd..d1479d7c8 100644 --- a/pkg/gui/services/custom_commands/client.go +++ b/pkg/gui/services/custom_commands/client.go @@ -4,7 +4,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" - "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/samber/lo" @@ -47,7 +46,7 @@ func (self *Client) GetCustomCommandKeybindings() ([]*types.Binding, error) { } bindings = append(bindings, &types.Binding{ ViewName: "", // custom commands menus are global; we filter the commands inside by context - Key: keybindings.GetKey(customCommand.Key), + Key: config.GetValidatedKeyBindingKey(customCommand.Key), Modifier: gocui.ModNone, Handler: handler, Description: getCustomCommandsMenuDescription(customCommand, self.c.Tr), @@ -75,7 +74,7 @@ func (self *Client) showCustomCommandsMenu(customCommand config.CustomCommand) e } menuItems = append(menuItems, &types.MenuItem{ Label: subCommand.GetDescription(), - Key: keybindings.GetKey(subCommand.Key), + Key: config.GetValidatedKeyBindingKey(subCommand.Key), OnPress: handler, OpensMenu: true, }) @@ -95,7 +94,7 @@ func (self *Client) showCustomCommandsMenu(customCommand config.CustomCommand) e menuItems = append(menuItems, &types.MenuItem{ Label: subCommand.GetDescription(), - Key: keybindings.GetKey(subCommand.Key), + Key: config.GetValidatedKeyBindingKey(subCommand.Key), OnPress: self.handlerCreator.call(subCommand), }) } diff --git a/pkg/gui/services/custom_commands/handler_creator.go b/pkg/gui/services/custom_commands/handler_creator.go index 816435588..412fc7de6 100644 --- a/pkg/gui/services/custom_commands/handler_creator.go +++ b/pkg/gui/services/custom_commands/handler_creator.go @@ -9,7 +9,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" - "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" @@ -233,7 +232,7 @@ func (self *HandlerCreator) menuPrompt(prompt *config.CustomCommandPrompt, wrapp OnPress: func() error { return wrappedF(option.Value) }, - Key: keybindings.GetKey(option.Key), + Key: config.GetValidatedKeyBindingKey(option.Key), } }) diff --git a/pkg/gui/services/custom_commands/keybinding_creator.go b/pkg/gui/services/custom_commands/keybinding_creator.go index d7acbcacc..a89beb287 100644 --- a/pkg/gui/services/custom_commands/keybinding_creator.go +++ b/pkg/gui/services/custom_commands/keybinding_creator.go @@ -8,7 +8,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" - "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) @@ -37,7 +36,7 @@ func (self *KeybindingCreator) call(customCommand config.CustomCommand, handler return lo.Map(viewNames, func(viewName string, _ int) *types.Binding { return &types.Binding{ ViewName: viewName, - Key: keybindings.GetKey(customCommand.Key), + Key: config.GetValidatedKeyBindingKey(customCommand.Key), Modifier: gocui.ModNone, Handler: handler, Description: customCommand.GetDescription(), From 22169e22ffc46c5aede253b739135e50043486ae Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Apr 2026 18:04:07 +0200 Subject: [PATCH 06/10] Move modifiers into Key This changes not only how we store modifiers (inside of Key instead of passing it separately), but also how we parse keybinding strings: it supports all combinations of modifiers now (if the terminal supports it, that is). --- docs-master/keybindings/Keybindings_en.md | 4 +- docs-master/keybindings/Keybindings_ja.md | 4 +- docs-master/keybindings/Keybindings_ko.md | 4 +- docs-master/keybindings/Keybindings_nl.md | 4 +- docs-master/keybindings/Keybindings_pl.md | 4 +- docs-master/keybindings/Keybindings_pt.md | 4 +- docs-master/keybindings/Keybindings_ru.md | 4 +- docs-master/keybindings/Keybindings_zh-CN.md | 4 +- docs-master/keybindings/Keybindings_zh-TW.md | 4 +- pkg/config/keynames.go | 223 ++++-- pkg/config/keynames_test.go | 686 ++++++++++++++++++ pkg/gocui/edit.go | 57 +- pkg/gocui/gui.go | 23 +- pkg/gocui/key.go | 21 +- pkg/gocui/keybinding.go | 52 +- pkg/gocui/tcell_driver.go | 39 +- .../commit_description_controller.go | 2 +- .../controllers/commit_message_controller.go | 2 +- pkg/gui/editors.go | 22 +- pkg/gui/gui_driver.go | 2 +- pkg/integration/clients/tui.go | 8 +- 21 files changed, 952 insertions(+), 221 deletions(-) create mode 100644 pkg/config/keynames_test.go diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index ca1541dd2..7e80ceb7a 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -221,8 +221,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` mouse wheel down (fn+up) `` | Scroll down | | -| `` mouse wheel up (fn+down) `` | Scroll up | | +| `` (fn+up) `` | Scroll down | | +| `` (fn+down) `` | Scroll up | | | `` `` | Switch view | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | Search the current view by text | | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index 69479db13..bbb53a74b 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -304,8 +304,8 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | Key | Action | Info | |-----|--------|-------------| -| `` mouse wheel down (fn+up) `` | 下にスクロール | | -| `` mouse wheel up (fn+down) `` | 上にスクロール | | +| `` (fn+up) `` | 下にスクロール | | +| `` (fn+down) `` | 上にスクロール | | | `` `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 | | `` `` | サイドパネルに戻る | | | `` / `` | 現在のビューをテキストで検索 | | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index eeb5ed885..0f18930f6 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -160,8 +160,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` mouse wheel down (fn+up) `` | 아래로 스크롤 | | -| `` mouse wheel up (fn+down) `` | 위로 스크롤 | | +| `` (fn+up) `` | 아래로 스크롤 | | +| `` (fn+down) `` | 위로 스크롤 | | | `` `` | 패널 전환 | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | 검색 시작 | | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 21b8c5b4c..b2d0d8c87 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -229,8 +229,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` mouse wheel down (fn+up) `` | Scroll omlaag | | -| `` mouse wheel up (fn+down) `` | Scroll omhoog | | +| `` (fn+up) `` | Scroll omlaag | | +| `` (fn+down) `` | Scroll omhoog | | | `` `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | Start met zoeken | | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index 622a134fd..672c0e3df 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -179,8 +179,8 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` mouse wheel down (fn+up) `` | Przewiń w dół | | -| `` mouse wheel up (fn+down) `` | Przewiń w górę | | +| `` (fn+up) `` | Przewiń w dół | | +| `` (fn+down) `` | Przewiń w górę | | | `` `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). | | `` `` | Exit back to side panel | | | `` / `` | Szukaj w bieżącym widoku po tekście | | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 81dc4085e..9bb9ab097 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -233,8 +233,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` mouse wheel down (fn+up) `` | Rolar para baixo | | -| `` mouse wheel up (fn+down) `` | Rolar para cima | | +| `` (fn+up) `` | Rolar para baixo | | +| `` (fn+down) `` | Rolar para cima | | | `` `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). | | `` `` | Exit back to side panel | | | `` / `` | Pesquisar na visualização atual por texto | | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index b4531eb73..bd738f3ed 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -104,8 +104,8 @@ _Связки клавиш_ | Key | Action | Info | |-----|--------|-------------| -| `` mouse wheel down (fn+up) `` | Прокрутить вниз | | -| `` mouse wheel up (fn+down) `` | Прокрутить вверх | | +| `` (fn+up) `` | Прокрутить вниз | | +| `` (fn+down) `` | Прокрутить вверх | | | `` `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | Найти | | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index 0385e486b..b16dff18c 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -332,8 +332,8 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` mouse wheel down (fn+up) `` | 向下滚动 | | -| `` mouse wheel up (fn+down) `` | 向上滚动 | | +| `` (fn+up) `` | 向下滚动 | | +| `` (fn+down) `` | 向上滚动 | | | `` `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) | | `` `` | 退出回到侧边面板 | | | `` / `` | 开始搜索 | | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index c0579e0ce..3d637160b 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -80,8 +80,8 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | Key | Action | Info | |-----|--------|-------------| -| `` mouse wheel down (fn+up) `` | 向下捲動 | | -| `` mouse wheel up (fn+down) `` | 向上捲動 | | +| `` (fn+up) `` | 向下捲動 | | +| `` (fn+down) `` | 向上捲動 | | | `` `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | 搜尋 | | diff --git a/pkg/config/keynames.go b/pkg/config/keynames.go index a6fe9e74e..5be386ecc 100644 --- a/pkg/config/keynames.go +++ b/pkg/config/keynames.go @@ -14,63 +14,35 @@ import ( // docs/keybindings/Custom_Keybindings.md as well var labelByKey = map[gocui.KeyName]string{ - gocui.KeyF1: "", - gocui.KeyF2: "", - gocui.KeyF3: "", - gocui.KeyF4: "", - gocui.KeyF5: "", - gocui.KeyF6: "", - gocui.KeyF7: "", - gocui.KeyF8: "", - gocui.KeyF9: "", - gocui.KeyF10: "", - gocui.KeyF11: "", - gocui.KeyF12: "", - gocui.KeyInsert: "", - gocui.KeyDelete: "", - gocui.KeyHome: "", - gocui.KeyEnd: "", - gocui.KeyPgup: "", - gocui.KeyPgdn: "", - gocui.KeyArrowUp: "", - gocui.KeyShiftArrowUp: "", - gocui.KeyArrowDown: "", - gocui.KeyShiftArrowDown: "", - gocui.KeyArrowLeft: "", - gocui.KeyArrowRight: "", - gocui.KeyTab: "", // - gocui.KeyBacktab: "", - gocui.KeyEnter: "", // - gocui.KeyAltEnter: "", - gocui.KeyEsc: "", // , - gocui.KeyBackspace: "", // - gocui.KeySpace: "", - gocui.KeyCtrlA: "", - gocui.KeyCtrlB: "", - gocui.KeyCtrlC: "", - gocui.KeyCtrlD: "", - gocui.KeyCtrlE: "", - gocui.KeyCtrlF: "", - gocui.KeyCtrlG: "", - gocui.KeyCtrlJ: "", - gocui.KeyCtrlK: "", - gocui.KeyCtrlL: "", - gocui.KeyCtrlN: "", - gocui.KeyCtrlO: "", - gocui.KeyCtrlP: "", - gocui.KeyCtrlQ: "", - gocui.KeyCtrlR: "", - gocui.KeyCtrlS: "", - gocui.KeyCtrlT: "", - gocui.KeyCtrlU: "", - gocui.KeyCtrlV: "", - gocui.KeyCtrlW: "", - gocui.KeyCtrlX: "", - gocui.KeyCtrlY: "", - gocui.KeyCtrlZ: "", - gocui.KeyCtrl8: "", - gocui.MouseWheelUp: "mouse wheel up", - gocui.MouseWheelDown: "mouse wheel down", + 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) @@ -80,16 +52,44 @@ func LabelForKey(key gocui.Key) string { return "" } + label := "" + if key.Mod()&gocui.ModCtrl != 0 { + label += "c-" + } + if key.Mod()&gocui.ModAlt != 0 { + label += "a-" + } + if key.Mod()&gocui.ModShift != 0 { + label += "s-" + } + if key.Mod()&gocui.ModMeta != 0 { + label += "m-" + } + if key.KeyName() == gocui.KeyName(tcell.KeyRune) { - return key.Str() + 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" + } } - value, ok := labelByKey[key.KeyName()] - if ok { - return value + if utf8.RuneCountInString(label) > 1 { + label = "<" + label + ">" } - return "unknown" + return label } func KeyFromLabel(label string) (gocui.Key, bool) { @@ -97,16 +97,99 @@ func KeyFromLabel(label string) (gocui.Key, bool) { return gocui.Key{}, true } - runeCount := utf8.RuneCountInString(label) - if runeCount > 1 { - keyName, ok := keyByLabel[strings.ToLower(label)] - if !ok { - return gocui.Key{}, false - } - return gocui.NewKeyName(keyName), true + if strings.HasPrefix(label, "<") && strings.HasSuffix(label, ">") { + label = label[1 : len(label)-1] } - return gocui.NewKeyRune([]rune(label)[0]), true + mod := gocui.ModNone + for { + // A bare "-" or "+" with any (or no) modifiers is a literal rune + // key; this also covers lenient forms like `` and ``, + // neither of which we emit (we use `` and ``). + 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 rather than . + 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 { diff --git a/pkg/config/keynames_test.go b/pkg/config/keynames_test.go new file mode 100644 index 000000000..0176a1b06 --- /dev/null +++ b/pkg/config/keynames_test.go @@ -0,0 +1,686 @@ +package config + +import ( + "testing" + + "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/stretchr/testify/assert" +) + +func TestKeyFromLabel(t *testing.T) { + scenarios := []struct { + name string + label string + expectedKey gocui.Key + expectedOk bool + }{ + // Empty / disabled + { + name: "empty string returns unset key", + label: "", + expectedKey: gocui.Key{}, + expectedOk: true, + }, + { + name: " returns unset key", + label: "", + expectedKey: gocui.Key{}, + expectedOk: true, + }, + + // Plain runes (unwrapped) + { + name: "single lowercase letter", + label: "a", + expectedKey: gocui.NewKeyStrMod("a", gocui.ModNone), + expectedOk: true, + }, + { + name: "single uppercase letter", + label: "A", + expectedKey: gocui.NewKeyStrMod("A", gocui.ModNone), + expectedOk: true, + }, + { + name: "single digit", + label: "5", + expectedKey: gocui.NewKeyStrMod("5", gocui.ModNone), + expectedOk: true, + }, + { + name: "punctuation rune", + label: "?", + expectedKey: gocui.NewKeyStrMod("?", gocui.ModNone), + expectedOk: true, + }, + { + name: "multibyte rune", + label: "ñ", + expectedKey: gocui.NewKeyStrMod("ñ", gocui.ModNone), + expectedOk: true, + }, + { + name: "bare dash is treated as a rune", + label: "-", + expectedKey: gocui.NewKeyRune('-'), + expectedOk: true, + }, + + // Special key names (no modifiers, no brackets — though these are + // always wrapped in brackets in real configs, KeyFromLabel accepts + // the unwrapped form too) + { + name: "function key", + label: "f1", + expectedKey: gocui.NewKey(gocui.KeyF1, "", gocui.ModNone), + expectedOk: true, + }, + { + name: "function key wrapped in brackets", + label: "", + expectedKey: gocui.NewKey(gocui.KeyF12, "", gocui.ModNone), + expectedOk: true, + }, + { + name: "arrow key", + label: "", + expectedKey: gocui.NewKey(gocui.KeyArrowUp, "", gocui.ModNone), + expectedOk: true, + }, + { + name: "tab", + label: "", + expectedKey: gocui.NewKey(gocui.KeyTab, "", gocui.ModNone), + expectedOk: true, + }, + { + name: "enter", + label: "", + expectedKey: gocui.NewKey(gocui.KeyEnter, "", gocui.ModNone), + expectedOk: true, + }, + { + name: "esc", + label: "", + expectedKey: gocui.NewKey(gocui.KeyEsc, "", gocui.ModNone), + expectedOk: true, + }, + { + name: "backspace", + label: "", + expectedKey: gocui.NewKey(gocui.KeyBackspace, "", gocui.ModNone), + expectedOk: true, + }, + { + name: "pgup", + label: "", + expectedKey: gocui.NewKey(gocui.KeyPgup, "", gocui.ModNone), + expectedOk: true, + }, + { + name: "pgdown", + label: "", + expectedKey: gocui.NewKey(gocui.KeyPgdn, "", gocui.ModNone), + expectedOk: true, + }, + { + name: "mouse wheel up", + label: "", + expectedKey: gocui.NewKey(gocui.MouseWheelUp, "", gocui.ModNone), + expectedOk: true, + }, + + // Space + { + name: "space keyword maps to space rune", + label: "", + expectedKey: gocui.NewKeyStrMod(" ", gocui.ModNone), + expectedOk: true, + }, + { + name: "space keyword without brackets", + label: "space", + expectedKey: gocui.NewKeyStrMod(" ", gocui.ModNone), + expectedOk: true, + }, + { + name: "ctrl+space", + label: "", + expectedKey: gocui.NewKeyStrMod(" ", gocui.ModCtrl), + expectedOk: true, + }, + + // Minus + { + name: "minus keyword maps to dash rune", + label: "", + expectedKey: gocui.NewKeyStrMod("-", gocui.ModNone), + expectedOk: true, + }, + { + name: "ctrl+minus via keyword", + label: "", + expectedKey: gocui.NewKeyStrMod("-", gocui.ModCtrl), + expectedOk: true, + }, + { + name: "ctrl+minus via lenient dash form", + label: "", + expectedKey: gocui.NewKeyStrMod("-", gocui.ModCtrl), + expectedOk: true, + }, + { + name: "alt+ctrl+minus via lenient dash form", + label: "", + expectedKey: gocui.NewKeyStrMod("-", gocui.ModAlt|gocui.ModCtrl), + expectedOk: true, + }, + + // Plus + { + name: "plus keyword maps to plus rune", + label: "", + expectedKey: gocui.NewKeyStrMod("+", gocui.ModNone), + expectedOk: true, + }, + { + name: "ctrl+plus via keyword", + label: "", + expectedKey: gocui.NewKeyStrMod("+", gocui.ModCtrl), + expectedOk: true, + }, + { + name: "ctrl+plus via long keyword and plus separator", + label: "", + expectedKey: gocui.NewKeyStrMod("+", gocui.ModCtrl), + expectedOk: true, + }, + { + name: "alt+shift+plus via keyword", + label: "", + expectedKey: gocui.NewKeyStrMod("+", gocui.ModAlt|gocui.ModShift), + expectedOk: true, + }, + { + name: "shift alone on plus is rejected", + label: "", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + + // Modifiers with runes + { + name: "ctrl+letter", + label: "", + expectedKey: gocui.NewKeyStrMod("a", gocui.ModCtrl), + expectedOk: true, + }, + { + name: "alt+letter", + label: "", + expectedKey: gocui.NewKeyStrMod("x", gocui.ModAlt), + expectedOk: true, + }, + { + name: "meta+letter", + label: "", + expectedKey: gocui.NewKeyStrMod("z", gocui.ModMeta), + expectedOk: true, + }, + + // Long modifier names are accepted as synonyms for the short forms. + { + name: "ctrl long form", + label: "", + expectedKey: gocui.NewKeyStrMod("a", gocui.ModCtrl), + expectedOk: true, + }, + { + name: "alt long form", + label: "", + expectedKey: gocui.NewKeyStrMod("x", gocui.ModAlt), + expectedOk: true, + }, + { + name: "meta long form", + label: "", + expectedKey: gocui.NewKeyStrMod("z", gocui.ModMeta), + expectedOk: true, + }, + { + name: "shift long form combined with ctrl", + label: "", + expectedKey: gocui.NewKeyStrMod("a", gocui.ModShift|gocui.ModCtrl), + expectedOk: true, + }, + { + name: "long forms work with special keys", + label: "", + expectedKey: gocui.NewKey(gocui.KeyArrowUp, "", gocui.ModCtrl), + expectedOk: true, + }, + { + name: "short and long forms can be mixed", + label: "", + expectedKey: gocui.NewKey(gocui.KeyArrowUp, "", gocui.ModCtrl|gocui.ModShift), + expectedOk: true, + }, + { + name: "duplicate via mixed short and long form is rejected", + label: "", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + { + name: "unknown long modifier is rejected", + label: "", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + + // Plus is accepted as an alternative modifier separator. + { + name: "plus separator with short form", + label: "", + expectedKey: gocui.NewKeyStrMod("a", gocui.ModCtrl), + expectedOk: true, + }, + { + name: "plus separator with long form", + label: "", + expectedKey: gocui.NewKeyStrMod("a", gocui.ModCtrl|gocui.ModAlt), + expectedOk: true, + }, + { + name: "plus separator with special key", + label: "", + expectedKey: gocui.NewKey(gocui.KeyArrowUp, "", gocui.ModCtrl), + expectedOk: true, + }, + { + name: "mixed plus and dash separators", + label: "", + expectedKey: gocui.NewKey(gocui.KeyArrowUp, "", gocui.ModCtrl|gocui.ModShift), + expectedOk: true, + }, + { + name: "duplicate detection works across separators", + label: "", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + { + name: "ctrl+plus rune via plus separator", + label: "", + expectedKey: gocui.NewKeyStrMod("+", gocui.ModCtrl), + expectedOk: true, + }, + { + name: "ctrl+dash rune via plus separator", + label: "", + expectedKey: gocui.NewKeyStrMod("-", gocui.ModCtrl), + expectedOk: true, + }, + { + name: "ctrl+plus rune via dash separator", + label: "", + expectedKey: gocui.NewKeyStrMod("+", gocui.ModCtrl), + expectedOk: true, + }, + { + name: "bare plus rune", + label: "+", + expectedKey: gocui.NewKeyStrMod("+", gocui.ModNone), + expectedOk: true, + }, + { + name: "bare plus wrapped in brackets", + label: "<+>", + expectedKey: gocui.NewKeyStrMod("+", gocui.ModNone), + expectedOk: true, + }, + + // Shift-on-rune is rejected: terminals fold shift into the rune + // itself, so the binding could never fire. Combined with other + // modifiers it's allowed (the terminal can't fold it then). + { + name: "shift alone on a letter is rejected", + label: "", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + { + name: "shift alone on uppercase letter is rejected", + label: "", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + { + name: "shift alone on minus is rejected", + label: "", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + { + name: "shift on space is allowed (rune does not change)", + label: "", + expectedKey: gocui.NewKeyStrMod(" ", gocui.ModShift), + expectedOk: true, + }, + { + name: "shift combined with ctrl on a letter is allowed", + label: "", + expectedKey: gocui.NewKeyStrMod("x", gocui.ModCtrl|gocui.ModShift), + expectedOk: true, + }, + { + name: "shift combined with alt on minus is allowed", + label: "", + expectedKey: gocui.NewKeyStrMod("-", gocui.ModAlt|gocui.ModShift), + expectedOk: true, + }, + + // Uppercase ASCII letter with a modifier is rejected: ctrl+letter + // always arrives with a lowercase rune (control codes have no case + // distinction), and CSI-u reports the unshifted codepoint with + // shift as a separate modifier. + { + name: "ctrl+uppercase letter is rejected", + label: "", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + { + name: "alt+uppercase letter is rejected", + label: "", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + { + name: "meta+uppercase letter is rejected", + label: "", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + { + name: "combined modifier on uppercase letter is rejected", + label: "", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + { + name: "bare uppercase letter is allowed", + label: "A", + expectedKey: gocui.NewKeyStrMod("A", gocui.ModNone), + expectedOk: true, + }, + { + name: "modifier on digit is allowed", + label: "", + expectedKey: gocui.NewKeyStrMod("1", gocui.ModCtrl), + expectedOk: true, + }, + { + name: "modifier on non-ASCII uppercase letter is allowed", + label: "", + expectedKey: gocui.NewKeyStrMod("Ñ", gocui.ModAlt), + expectedOk: true, + }, + + // Modifiers with special keys + { + name: "ctrl+enter", + label: "", + expectedKey: gocui.NewKey(gocui.KeyEnter, "", gocui.ModCtrl), + expectedOk: true, + }, + { + name: "alt+up", + label: "", + expectedKey: gocui.NewKey(gocui.KeyArrowUp, "", gocui.ModAlt), + expectedOk: true, + }, + { + name: "shift+f1", + label: "", + expectedKey: gocui.NewKey(gocui.KeyF1, "", gocui.ModShift), + expectedOk: true, + }, + { + name: "meta+enter", + label: "", + expectedKey: gocui.NewKey(gocui.KeyEnter, "", gocui.ModMeta), + expectedOk: true, + }, + + // Combined modifiers + { + name: "ctrl+alt+letter", + label: "", + expectedKey: gocui.NewKeyStrMod("x", gocui.ModCtrl|gocui.ModAlt), + expectedOk: true, + }, + { + name: "all four modifiers on a letter", + label: "", + expectedKey: gocui.NewKeyStrMod("x", gocui.ModShift|gocui.ModCtrl|gocui.ModAlt|gocui.ModMeta), + expectedOk: true, + }, + { + name: "ctrl+shift+arrow key", + label: "", + expectedKey: gocui.NewKey(gocui.KeyArrowUp, "", gocui.ModCtrl|gocui.ModShift), + expectedOk: true, + }, + + // Bracket handling + { + name: "single rune wrapped in brackets is unwrapped", + label: "", + expectedKey: gocui.NewKeyStrMod("a", gocui.ModNone), + expectedOk: true, + }, + { + name: "dash wrapped in brackets", + label: "<->", + expectedKey: gocui.NewKeyRune('-'), + expectedOk: true, + }, + + // Invalid inputs + { + name: "unknown special key name", + label: "", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + { + name: "unknown modifier letter", + label: "", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + { + name: "uppercase modifier is not accepted", + label: "", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + { + name: "duplicate ctrl modifier", + label: "", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + { + name: "duplicate shift modifier", + label: "", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + { + name: "duplicate alt modifier", + label: "", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + { + name: "duplicate meta modifier", + label: "", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + { + name: "trailing modifier with no key", + label: "", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + { + name: "multi-character non-special label", + label: "ab", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + { + name: "empty brackets", + label: "<>", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + { + name: "modifier on unknown key name", + label: "", + expectedKey: gocui.Key{}, + expectedOk: false, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + key, ok := KeyFromLabel(s.label) + assert.Equal(t, s.expectedOk, ok) + assert.Equal(t, s.expectedKey, key) + }) + } +} + +func TestLabelForKey(t *testing.T) { + scenarios := []struct { + name string + key gocui.Key + expected string + }{ + // Unset + {"unset key produces empty string", gocui.Key{}, ""}, + + // Plain runes — single-character output, no brackets + {"lowercase letter", gocui.NewKeyStrMod("a", gocui.ModNone), "a"}, + {"uppercase letter", gocui.NewKeyStrMod("A", gocui.ModNone), "A"}, + {"digit", gocui.NewKeyStrMod("5", gocui.ModNone), "5"}, + {"punctuation", gocui.NewKeyStrMod("?", gocui.ModNone), "?"}, + {"slash", gocui.NewKeyStrMod("/", gocui.ModNone), "/"}, + {"multibyte rune", gocui.NewKeyStrMod("ñ", gocui.ModNone), "ñ"}, + + // Space and dash — special-cased rune output + {"plain dash uses literal", gocui.NewKeyStrMod("-", gocui.ModNone), "-"}, + {"plain space uses keyword", gocui.NewKeyStrMod(" ", gocui.ModNone), ""}, + {"ctrl+dash uses minus keyword", gocui.NewKeyStrMod("-", gocui.ModCtrl), ""}, + {"alt+dash uses minus keyword", gocui.NewKeyStrMod("-", gocui.ModAlt), ""}, + {"plain plus uses literal", gocui.NewKeyStrMod("+", gocui.ModNone), "+"}, + {"ctrl+plus uses plus keyword", gocui.NewKeyStrMod("+", gocui.ModCtrl), ""}, + {"alt+plus uses plus keyword", gocui.NewKeyStrMod("+", gocui.ModAlt), ""}, + {"ctrl+space", gocui.NewKeyStrMod(" ", gocui.ModCtrl), ""}, + + // Single modifier on a rune + {"ctrl+letter", gocui.NewKeyStrMod("a", gocui.ModCtrl), ""}, + {"alt+letter", gocui.NewKeyStrMod("x", gocui.ModAlt), ""}, + {"meta+letter", gocui.NewKeyStrMod("z", gocui.ModMeta), ""}, + {"shift+space", gocui.NewKeyStrMod(" ", gocui.ModShift), ""}, + + // Modifier ordering — canonical output is c-, a-, s-, m- + {"ctrl+alt orders c before a", gocui.NewKeyStrMod("x", gocui.ModCtrl|gocui.ModAlt), ""}, + {"shift+ctrl orders c before s", gocui.NewKeyStrMod("x", gocui.ModShift|gocui.ModCtrl), ""}, + {"meta+shift orders s before m", gocui.NewKeyStrMod("x", gocui.ModMeta|gocui.ModShift), ""}, + { + "all four modifiers ordered c-a-s-m", + gocui.NewKeyStrMod("x", gocui.ModCtrl|gocui.ModAlt|gocui.ModShift|gocui.ModMeta), + "", + }, + + // Special keys (always wrapped, even unmodified) + {"f1", gocui.NewKey(gocui.KeyF1, "", gocui.ModNone), ""}, + {"f12", gocui.NewKey(gocui.KeyF12, "", gocui.ModNone), ""}, + {"insert", gocui.NewKey(gocui.KeyInsert, "", gocui.ModNone), ""}, + {"delete", gocui.NewKey(gocui.KeyDelete, "", gocui.ModNone), ""}, + {"home", gocui.NewKey(gocui.KeyHome, "", gocui.ModNone), ""}, + {"end", gocui.NewKey(gocui.KeyEnd, "", gocui.ModNone), ""}, + {"pgup", gocui.NewKey(gocui.KeyPgup, "", gocui.ModNone), ""}, + {"pgdown", gocui.NewKey(gocui.KeyPgdn, "", gocui.ModNone), ""}, + {"arrow up", gocui.NewKey(gocui.KeyArrowUp, "", gocui.ModNone), ""}, + {"arrow down", gocui.NewKey(gocui.KeyArrowDown, "", gocui.ModNone), ""}, + {"arrow left", gocui.NewKey(gocui.KeyArrowLeft, "", gocui.ModNone), ""}, + {"arrow right", gocui.NewKey(gocui.KeyArrowRight, "", gocui.ModNone), ""}, + {"tab", gocui.NewKey(gocui.KeyTab, "", gocui.ModNone), ""}, + {"backtab", gocui.NewKey(gocui.KeyBacktab, "", gocui.ModNone), ""}, + {"enter", gocui.NewKey(gocui.KeyEnter, "", gocui.ModNone), ""}, + {"esc", gocui.NewKey(gocui.KeyEsc, "", gocui.ModNone), ""}, + {"backspace", gocui.NewKey(gocui.KeyBackspace, "", gocui.ModNone), ""}, + {"mouse wheel up", gocui.NewKey(gocui.MouseWheelUp, "", gocui.ModNone), ""}, + {"mouse wheel down", gocui.NewKey(gocui.MouseWheelDown, "", gocui.ModNone), ""}, + + // Modifiers on special keys + {"shift+f1", gocui.NewKey(gocui.KeyF1, "", gocui.ModShift), ""}, + {"alt+up", gocui.NewKey(gocui.KeyArrowUp, "", gocui.ModAlt), ""}, + {"meta+enter", gocui.NewKey(gocui.KeyEnter, "", gocui.ModMeta), ""}, + {"ctrl+shift+up", gocui.NewKey(gocui.KeyArrowUp, "", gocui.ModCtrl|gocui.ModShift), ""}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + assert.Equal(t, s.expected, LabelForKey(s.key)) + }) + } +} + +// Round-trip: every label produced by LabelForKey should parse back to the +// same key via KeyFromLabel. +func TestKeyFromLabel_RoundTripFromLabelForKey(t *testing.T) { + scenarios := []struct { + name string + key gocui.Key + }{ + {"unset key", gocui.Key{}}, + {"plain letter", gocui.NewKeyStrMod("a", gocui.ModNone)}, + {"plain digit", gocui.NewKeyStrMod("7", gocui.ModNone)}, + {"space", gocui.NewKeyStrMod(" ", gocui.ModNone)}, + {"ctrl+letter", gocui.NewKeyStrMod("a", gocui.ModCtrl)}, + {"alt+letter", gocui.NewKeyStrMod("x", gocui.ModAlt)}, + {"meta+letter", gocui.NewKeyStrMod("z", gocui.ModMeta)}, + {"shift+space", gocui.NewKeyStrMod(" ", gocui.ModShift)}, + {"ctrl+shift+letter", gocui.NewKeyStrMod("x", gocui.ModCtrl|gocui.ModShift)}, + {"ctrl+alt+letter", gocui.NewKeyStrMod("x", gocui.ModCtrl|gocui.ModAlt)}, + {"f1", gocui.NewKey(gocui.KeyF1, "", gocui.ModNone)}, + {"shift+f1", gocui.NewKey(gocui.KeyF1, "", gocui.ModShift)}, + {"alt+up", gocui.NewKey(gocui.KeyArrowUp, "", gocui.ModAlt)}, + {"meta+enter", gocui.NewKey(gocui.KeyEnter, "", gocui.ModMeta)}, + {"esc", gocui.NewKey(gocui.KeyEsc, "", gocui.ModNone)}, + {"mouse wheel up", gocui.NewKey(gocui.MouseWheelUp, "", gocui.ModNone)}, + {"ctrl+space", gocui.NewKeyStrMod(" ", gocui.ModCtrl)}, + {"plain dash", gocui.NewKeyStrMod("-", gocui.ModNone)}, + {"ctrl+dash", gocui.NewKeyStrMod("-", gocui.ModCtrl)}, + {"alt+shift+dash", gocui.NewKeyStrMod("-", gocui.ModAlt|gocui.ModShift)}, + {"plain plus", gocui.NewKeyStrMod("+", gocui.ModNone)}, + {"ctrl+plus", gocui.NewKeyStrMod("+", gocui.ModCtrl)}, + {"alt+shift+plus", gocui.NewKeyStrMod("+", gocui.ModAlt|gocui.ModShift)}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + label := LabelForKey(s.key) + parsed, ok := KeyFromLabel(label) + assert.True(t, ok, "expected label %q to parse", label) + assert.Equal(t, s.key, parsed) + }) + } +} diff --git a/pkg/gocui/edit.go b/pkg/gocui/edit.go index a11993a48..32593957d 100644 --- a/pkg/gocui/edit.go +++ b/pkg/gocui/edit.go @@ -6,63 +6,66 @@ package gocui // Editor interface must be satisfied by gocui editors. type Editor interface { - Edit(v *View, key Key, mod Modifier) bool + Edit(v *View, key Key) bool } // The EditorFunc type is an adapter to allow the use of ordinary functions as // Editors. If f is a function with the appropriate signature, EditorFunc(f) // is an Editor object that calls f. -type EditorFunc func(v *View, key Key, mod Modifier) bool +type EditorFunc func(v *View, key Key) bool // Edit calls f(v, key, mod) -func (f EditorFunc) Edit(v *View, key Key, mod Modifier) bool { - return f(v, key, mod) +func (f EditorFunc) Edit(v *View, key Key) bool { + return f(v, key) } // DefaultEditor is the default editor. var DefaultEditor Editor = EditorFunc(SimpleEditor) // SimpleEditor is used as the default gocui editor. -func SimpleEditor(v *View, key Key, mod Modifier) bool { +func SimpleEditor(v *View, key Key) bool { switch { - case (key.KeyName() == KeyBackspace || key.KeyName() == KeyBackspace2) && (mod&ModAlt) != 0, - key.KeyName() == KeyCtrlW: + case key.Equals(NewKey(KeyBackspace, "", ModAlt)), + key.Equals(NewKeyStrMod("w", ModCtrl)): v.TextArea.BackSpaceWord() - case key.KeyName() == KeyBackspace || key.KeyName() == KeyBackspace2 || key.KeyName() == KeyCtrlH: + case key.Equals(NewKeyName(KeyBackspace)): v.TextArea.BackSpaceChar() - case key.KeyName() == KeyCtrlD || key.KeyName() == KeyDelete: + case key.Equals(NewKeyStrMod("d", ModCtrl)), + key.Equals(NewKeyName(KeyDelete)): v.TextArea.DeleteChar() - case key.KeyName() == KeyArrowDown: + case key.Equals(NewKeyName(KeyArrowDown)): v.TextArea.MoveCursorDown() - case key.KeyName() == KeyArrowUp: + case key.Equals(NewKeyName(KeyArrowUp)): v.TextArea.MoveCursorUp() - case (key.KeyName() == KeyArrowLeft || key.Equals(NewKeyRune('b'))) && (mod&ModAlt) != 0: + case key.Equals(NewKeyStrMod("b", ModAlt)), + key.Equals(NewKey(KeyArrowLeft, "", ModAlt)): v.TextArea.MoveLeftWord() - case key.KeyName() == KeyArrowLeft || key.KeyName() == KeyCtrlB: + case key.Equals(NewKeyName(KeyArrowLeft)), + key.Equals(NewKeyStrMod("b", ModCtrl)): v.TextArea.MoveCursorLeft() - case (key.KeyName() == KeyArrowRight || key.Equals(NewKeyRune('f'))) && (mod&ModAlt) != 0: + case key.Equals(NewKeyStrMod("f", ModAlt)), + key.Equals(NewKey(KeyArrowRight, "", ModAlt)): v.TextArea.MoveRightWord() - case key.KeyName() == KeyArrowRight || key.KeyName() == KeyCtrlF: + case key.Equals(NewKeyName(KeyArrowRight)), + key.Equals(NewKeyStrMod("b", ModCtrl)): v.TextArea.MoveCursorRight() - case key.KeyName() == KeyEnter: + case key.Equals(NewKeyName(KeyEnter)): v.TextArea.TypeCharacter("\n") - case key.KeyName() == KeySpace: - v.TextArea.TypeCharacter(" ") - case key.KeyName() == KeyInsert: + case key.Equals(NewKeyName(KeyInsert)): v.TextArea.ToggleOverwrite() - case key.KeyName() == KeyCtrlU: + case key.Equals(NewKeyStrMod("u", ModCtrl)): v.TextArea.DeleteToStartOfLine() - case key.KeyName() == KeyCtrlK: + case key.Equals(NewKeyStrMod("k", ModCtrl)): v.TextArea.DeleteToEndOfLine() - case key.KeyName() == KeyCtrlA || key.KeyName() == KeyHome: + case key.Equals(NewKeyStrMod("a", ModCtrl)), + key.Equals(NewKeyName(KeyHome)): v.TextArea.GoToStartOfLine() - case key.KeyName() == KeyCtrlE || key.KeyName() == KeyEnd: + case key.Equals(NewKeyStrMod("e", ModCtrl)), + key.Equals(NewKeyName(KeyEnd)): v.TextArea.GoToEndOfLine() - case key.KeyName() == KeyCtrlW: - v.TextArea.BackSpaceWord() - case key.KeyName() == KeyCtrlY: + case key.Equals(NewKeyStrMod("y", ModCtrl)): v.TextArea.Yank() - case key.Str() != "": + case key.Str() != "" && key.Mod() == 0: v.TextArea.TypeCharacter(key.Str()) default: return false diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index e4320231e..9aaca3e2a 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -1264,17 +1264,16 @@ func (g *Gui) onKey(ev *GocuiEvent) error { switch ev.Type { case eventKey: - // When pasting text in Ghostty, it sends us '\r' instead of '\n' for // newlines. I actually don't quite understand why, because from reading - // Ghostty's source code (e.g. + // When pasting text in Ghostty, it sends us '\r' (which is delivered as + // ctrl-j by tcell) instead of '\n' for newlines. I actually don't quite + // understand why, because from reading Ghostty's source code (e.g. // https://github.com/ghostty-org/ghostty/commit/010338354a0) it does // this conversion only for non-bracketed paste mode, but I'm seeing it // in bracketed paste mode. Whatever I'm missing here, converting '\r' // back to '\n' fixes pasting multi-line text from Ghostty, and doesn't // seem harmful for other terminal emulators. - // - // KeyCtrlJ (int value 10) is '\r'. - if g.IsPasting && ev.Key.KeyName() == KeyCtrlJ { + if g.IsPasting && ev.Key.Equals(NewKeyStrMod("j", ModCtrl)) { ev.Key = NewKeyName(KeyEnter) } @@ -1316,7 +1315,7 @@ func (g *Gui) onKey(ev *GocuiEvent) error { } } - if ev.Key.KeyName() == MouseLeft && (ev.Mod&ModMotion) == 0 && !v.Editable && g.openHyperlink != nil { + if ev.Key.KeyName() == MouseLeft && (ev.Key.Mod()&ModMotion) == 0 && !v.Editable && g.openHyperlink != nil { if newY >= 0 && newY <= len(v.viewLines)-1 && newX >= 0 && newX <= len(v.viewLines[newY].line)-1 { if link := v.viewLines[newY].line[newX].hyperlink; link != "" { return g.openHyperlink(link, v.name) @@ -1424,7 +1423,7 @@ func (g *Gui) execMouseKeybindings(view *View, ev *GocuiEvent, opts ViewMouseBin isMatch := func(binding *ViewMouseBinding) bool { return binding.ViewName == view.Name() && ev.Key.KeyName() == binding.Key && - ev.Mod == binding.Modifier + ev.Key.Mod() == binding.Modifier } // first pass looks for ones that match the focused view @@ -1486,7 +1485,7 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) error { } // if we're searching, and we've hit n/N/Esc, we ignore the default keybinding - if v != nil && v.IsSearching() && ev.Mod == ModNone { + if v != nil && v.IsSearching() { if ev.Key.Equals(g.NextSearchMatchKey) { return v.gotoNextMatch() } else if ev.Key.Equals(g.PrevSearchMatchKey) { @@ -1508,7 +1507,7 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) error { if kb.handler == nil { continue } - if !kb.matchKeypress(ev.Key, ev.Mod) { + if !kb.matchKeypress(ev.Key) { continue } if g.matchView(v, kb) { @@ -1523,7 +1522,7 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) error { if v != nil && g.matchView(v.ParentView, kb) { matchingParentViewKb = kb } - if globalKb == nil && kb.viewName == "" && ((v != nil && !v.Editable) || (kb.key.keyName != KeyCtrlU && kb.key.keyName != KeyCtrlA && kb.key.keyName != KeyCtrlE)) { + if globalKb == nil && kb.viewName == "" { globalKb = kb } } @@ -1535,7 +1534,7 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) error { } if g.currentView != nil && g.currentView.Editable && g.currentView.Editor != nil { - matched := g.currentView.Editor.Edit(g.currentView, ev.Key, ev.Mod) + matched := g.currentView.Editor.Edit(g.currentView, ev.Key) if matched { return nil } @@ -1595,7 +1594,7 @@ func (g *Gui) matchView(v *View, kb *keybinding) bool { if v == nil { return false } - if v.Editable && kb.key.Str() != "" { + if v.Editable && kb.key.Str() != "" && kb.key.Mod() == 0 { return false } if kb.viewName != v.name { diff --git a/pkg/gocui/key.go b/pkg/gocui/key.go index 2a2091696..dd0a912a0 100644 --- a/pkg/gocui/key.go +++ b/pkg/gocui/key.go @@ -9,12 +9,15 @@ import "github.com/gdamore/tcell/v3" type Key struct { keyName KeyName str string + + mod Modifier } -func NewKey(keyName KeyName, str string) Key { +func NewKey(keyName KeyName, str string, mod Modifier) Key { return Key{ keyName: keyName, str: str, + mod: mod, } } @@ -22,6 +25,7 @@ func NewKeyName(keyName KeyName) Key { return Key{ keyName: keyName, str: "", + mod: ModNone, } } @@ -29,6 +33,15 @@ func NewKeyRune(ch rune) Key { return Key{ keyName: KeyName(tcell.KeyRune), str: string(ch), + mod: ModNone, + } +} + +func NewKeyStrMod(str string, mod Modifier) Key { + return Key{ + keyName: KeyName(tcell.KeyRune), + str: str, + mod: mod, } } @@ -40,10 +53,14 @@ func (k Key) Str() string { return k.str } +func (k Key) Mod() Modifier { + return k.mod +} + func (k Key) IsSet() bool { return k.keyName != 0 } func (k Key) Equals(otherKey Key) bool { - return k.keyName == otherKey.keyName && k.str == otherKey.str + return k.keyName == otherKey.keyName && k.str == otherKey.str && k.mod == otherKey.mod } diff --git a/pkg/gocui/keybinding.go b/pkg/gocui/keybinding.go index cdd2f0c0a..b80cb5df6 100644 --- a/pkg/gocui/keybinding.go +++ b/pkg/gocui/keybinding.go @@ -35,8 +35,8 @@ func newKeybinding(viewname string, key Key, mod Modifier, handler func(*Gui, *V } // matchKeypress returns if the keybinding matches the keypress. -func (kb *keybinding) matchKeypress(key Key, mod Modifier) bool { - return kb.key.Equals(key) && kb.mod == mod +func (kb *keybinding) matchKeypress(key Key) bool { + return kb.key.Equals(key) } // Special keys. @@ -69,41 +69,12 @@ const ( // Keys combinations. const ( - KeyCtrlTilde = KeyName(tcell.KeyF64) // arbitrary assignment - KeyCtrlA = KeyName(tcell.KeyCtrlA) - KeyCtrlB = KeyName(tcell.KeyCtrlB) - KeyCtrlC = KeyName(tcell.KeyCtrlC) - KeyCtrlD = KeyName(tcell.KeyCtrlD) - KeyCtrlE = KeyName(tcell.KeyCtrlE) - KeyCtrlF = KeyName(tcell.KeyCtrlF) - KeyCtrlG = KeyName(tcell.KeyCtrlG) - KeyBackspace = KeyName(tcell.KeyBackspace) - KeyCtrlH = KeyName(tcell.KeyCtrlH) - KeyTab = KeyName(tcell.KeyTab) - KeyBacktab = KeyName(tcell.KeyBacktab) - KeyCtrlI = KeyName(tcell.KeyCtrlI) - KeyCtrlJ = KeyName(tcell.KeyCtrlJ) - KeyCtrlK = KeyName(tcell.KeyCtrlK) - KeyCtrlL = KeyName(tcell.KeyCtrlL) - KeyEnter = KeyName(tcell.KeyEnter) - KeyCtrlM = KeyName(tcell.KeyCtrlM) - KeyCtrlN = KeyName(tcell.KeyCtrlN) - KeyCtrlO = KeyName(tcell.KeyCtrlO) - KeyCtrlP = KeyName(tcell.KeyCtrlP) - KeyCtrlQ = KeyName(tcell.KeyCtrlQ) - KeyCtrlR = KeyName(tcell.KeyCtrlR) - KeyCtrlS = KeyName(tcell.KeyCtrlS) - KeyCtrlT = KeyName(tcell.KeyCtrlT) - KeyCtrlU = KeyName(tcell.KeyCtrlU) - KeyCtrlV = KeyName(tcell.KeyCtrlV) - KeyCtrlW = KeyName(tcell.KeyCtrlW) - KeyCtrlX = KeyName(tcell.KeyCtrlX) - KeyCtrlY = KeyName(tcell.KeyCtrlY) - KeyCtrlZ = KeyName(tcell.KeyCtrlZ) - KeyEsc = KeyName(tcell.KeyEscape) - KeySpace = KeyName(32) - KeyBackspace2 = KeyName(tcell.KeyBackspace2) - KeyCtrl8 = KeyName(tcell.KeyBackspace2) // same key as in termbox-go + KeyCtrlTilde = KeyName(tcell.KeyF64) // arbitrary assignment + KeyBackspace = KeyName(tcell.KeyBackspace) + KeyTab = KeyName(tcell.KeyTab) + KeyBacktab = KeyName(tcell.KeyBacktab) + KeyEnter = KeyName(tcell.KeyEnter) + KeyEsc = KeyName(tcell.KeyEscape) // The following assignments were used in termbox implementation. // In tcell, these are not keys per se. But in gocui we have them @@ -123,8 +94,9 @@ const ( // Modifiers. const ( ModNone Modifier = Modifier(0) + ModShift = Modifier(tcell.ModShift) + ModCtrl = Modifier(tcell.ModCtrl) ModAlt = Modifier(tcell.ModAlt) - ModMotion = Modifier(2) // just picking an arbitrary number here that doesn't clash with tcell.ModAlt - // ModCtrl doesn't work with keyboard keys. Use CtrlKey in Key and ModNone. This is was for mouse clicks only (tcell.v1) - // ModCtrl = Modifier(tcell.ModCtrl) + ModMeta = Modifier(tcell.ModMeta) + ModMotion = Modifier(16) // just picking an arbitrary number here that doesn't clash with tcell's modifiers ) diff --git a/pkg/gocui/tcell_driver.go b/pkg/gocui/tcell_driver.go index 2e5278a37..b2fd40c19 100644 --- a/pkg/gocui/tcell_driver.go +++ b/pkg/gocui/tcell_driver.go @@ -163,7 +163,6 @@ type gocuiEventType uint8 // The 'Err' field is valid if 'Type' is 'eventError'. type GocuiEvent struct { Type gocuiEventType - Mod Modifier Key Key Width int Height int @@ -294,42 +293,15 @@ func (g *Gui) pollEvent() GocuiEvent { ch := "" if k == tcell.KeyRune { ch = tev.Str() - if ch == " " { - // special handling for spacebar - k = tcell.Key(KeySpace) - ch = "" - } + } else if k >= tcell.KeyCtrlA && k <= tcell.KeyCtrlZ { + ch = string(rune('a' + (k - tcell.KeyCtrlA))) + k = tcell.KeyRune } mod := tev.Modifiers() - // remove control modifier and setup special handling of ctrl+spacebar, etc. - if mod == tcell.ModCtrl && k == 32 { - ch = " " - k = tcell.KeyRune - } else if mod == tcell.ModShift && k == tcell.KeyUp { - mod = 0 - ch = "" - k = tcell.KeyF62 - } else if mod == tcell.ModShift && k == tcell.KeyDown { - mod = 0 - ch = "" - k = tcell.KeyF63 - } else if mod == tcell.ModCtrl || mod == tcell.ModShift { - // remove Ctrl or Shift if specified - // - shift - will be translated to the final code of rune - // - ctrl - is translated in the key - mod = 0 - } else if mod == tcell.ModAlt && k == tcell.KeyEnter { - // for the sake of convenience I'm having a KeyAltEnter key. I will likely - // regret this laziness in the future. We're arbitrarily mapping that to tcell's - // KeyF64. - mod = 0 - k = tcell.KeyF64 - } return GocuiEvent{ Type: eventKey, - Key: NewKey(KeyName(k), ch), - Mod: Modifier(mod), + Key: NewKey(KeyName(k), ch, Modifier(mod)), } case *tcell.EventMouse: x, y := tev.Position() @@ -411,8 +383,7 @@ func (g *Gui) pollEvent() GocuiEvent { Type: eventMouse, MouseX: x, MouseY: y, - Key: NewKeyName(mouseKey), - Mod: mouseMod, + Key: NewKey(mouseKey, "", mouseMod), } case *tcell.EventFocus: return GocuiEvent{ diff --git a/pkg/gui/controllers/commit_description_controller.go b/pkg/gui/controllers/commit_description_controller.go index 09b518659..63f6876e5 100644 --- a/pkg/gui/controllers/commit_description_controller.go +++ b/pkg/gui/controllers/commit_description_controller.go @@ -119,7 +119,7 @@ func (self *CommitDescriptionController) handleTogglePanel() error { // which is common in pasted code snippets. view := self.Context().GetView() for range 4 { - view.Editor.Edit(view, gocui.NewKeyRune(' '), 0) + view.Editor.Edit(view, gocui.NewKeyRune(' ')) } return nil } diff --git a/pkg/gui/controllers/commit_message_controller.go b/pkg/gui/controllers/commit_message_controller.go index 97865f741..e1561690c 100644 --- a/pkg/gui/controllers/commit_message_controller.go +++ b/pkg/gui/controllers/commit_message_controller.go @@ -130,7 +130,7 @@ func (self *CommitMessageController) handleTogglePanel() error { // switch to the description panel. view := self.context().GetView() for range 4 { - view.Editor.Edit(view, gocui.NewKeyRune(' '), 0) + view.Editor.Edit(view, gocui.NewKeyRune(' ')) } return nil } diff --git a/pkg/gui/editors.go b/pkg/gui/editors.go index bf633db9e..7d3a93de3 100644 --- a/pkg/gui/editors.go +++ b/pkg/gui/editors.go @@ -4,33 +4,33 @@ import ( "github.com/jesseduffield/lazygit/pkg/gocui" ) -func (gui *Gui) handleEditorKeypress(v *gocui.View, key gocui.Key, mod gocui.Modifier, allowMultiline bool) bool { - if key.KeyName() == gocui.KeyEnter && allowMultiline { +func (gui *Gui) handleEditorKeypress(v *gocui.View, key gocui.Key, allowMultiline bool) bool { + if key.Equals(gocui.NewKeyName(gocui.KeyEnter)) && allowMultiline { v.TextArea.TypeCharacter("\n") v.RenderTextArea() return true } - return gocui.DefaultEditor.Edit(v, key, mod) + return gocui.DefaultEditor.Edit(v, key) } // we've just copy+pasted the editor from gocui to here so that we can also re- // render the commit message length on each keypress -func (gui *Gui) commitMessageEditor(v *gocui.View, key gocui.Key, mod gocui.Modifier) bool { - matched := gui.handleEditorKeypress(v, key, mod, false) +func (gui *Gui) commitMessageEditor(v *gocui.View, key gocui.Key) bool { + matched := gui.handleEditorKeypress(v, key, false) v.RenderTextArea() gui.c.Contexts().CommitMessage.RenderSubtitle() return matched } -func (gui *Gui) commitDescriptionEditor(v *gocui.View, key gocui.Key, mod gocui.Modifier) bool { - matched := gui.handleEditorKeypress(v, key, mod, true) +func (gui *Gui) commitDescriptionEditor(v *gocui.View, key gocui.Key) bool { + matched := gui.handleEditorKeypress(v, key, true) v.RenderTextArea() return matched } -func (gui *Gui) promptEditor(v *gocui.View, key gocui.Key, mod gocui.Modifier) bool { - matched := gui.handleEditorKeypress(v, key, mod, false) +func (gui *Gui) promptEditor(v *gocui.View, key gocui.Key) bool { + matched := gui.handleEditorKeypress(v, key, false) v.RenderTextArea() @@ -46,8 +46,8 @@ func (gui *Gui) promptEditor(v *gocui.View, key gocui.Key, mod gocui.Modifier) b return matched } -func (gui *Gui) searchEditor(v *gocui.View, key gocui.Key, mod gocui.Modifier) bool { - matched := gui.handleEditorKeypress(v, key, mod, false) +func (gui *Gui) searchEditor(v *gocui.View, key gocui.Key) bool { + matched := gui.handleEditorKeypress(v, key, false) v.RenderTextArea() searchString := v.TextArea.GetContent() diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index f70e8c9b3..08f3ecf62 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -34,7 +34,7 @@ func (self *GuiDriver) PressKey(keyStr string) { } self.gui.g.ReplayedEvents.Keys <- gocui.NewTcellKeyEventWrapper( - tcell.NewEventKey(tcell.Key(key.KeyName()), key.Str(), tcell.ModNone), + tcell.NewEventKey(tcell.Key(key.KeyName()), key.Str(), tcell.ModMask(key.Mod())), 0, ) diff --git a/pkg/integration/clients/tui.go b/pkg/integration/clients/tui.go index 426f633de..90f02cc32 100644 --- a/pkg/integration/clients/tui.go +++ b/pkg/integration/clients/tui.go @@ -72,7 +72,7 @@ func RunTUI(raceDetector bool) { log.Panicln(err) } - if err := g.SetKeybinding("list", gocui.NewKeyName(gocui.KeyCtrlC), gocui.ModNone, quit); err != nil { + if err := g.SetKeybinding("list", gocui.NewKeyStrMod("c", gocui.ModCtrl), gocui.ModNone, quit); err != nil { log.Panicln(err) } @@ -273,9 +273,9 @@ func (self *app) renderTests() { } } -func (self *app) wrapEditor(f func(v *gocui.View, key gocui.Key, mod gocui.Modifier) bool) func(v *gocui.View, key gocui.Key, mod gocui.Modifier) bool { - return func(v *gocui.View, key gocui.Key, mod gocui.Modifier) bool { - matched := f(v, key, mod) +func (self *app) wrapEditor(f func(v *gocui.View, key gocui.Key) bool) func(v *gocui.View, key gocui.Key) bool { + return func(v *gocui.View, key gocui.Key) bool { + matched := f(v, key) if matched { self.filterWithString(v.TextArea.GetContent()) } From 09d671e6428736db33826a98e3cc1cdf5516229b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 30 Apr 2026 18:06:11 +0200 Subject: [PATCH 07/10] Document custom keybinding syntax The previous version only enumerated the supported (non-rune) bindings. Replace it with a description of the syntax: how to spell single-rune keys, special keys, and modified keys; how to combine modifiers; the keyword forms for ``, ``, ``; and which combinations are rejected because terminals can't deliver them. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs-master/keybindings/Custom_Keybindings.md | 159 +++++++++++------- 1 file changed, 96 insertions(+), 63 deletions(-) diff --git a/docs-master/keybindings/Custom_Keybindings.md b/docs-master/keybindings/Custom_Keybindings.md index a2537f069..15fc41d5c 100644 --- a/docs-master/keybindings/Custom_Keybindings.md +++ b/docs-master/keybindings/Custom_Keybindings.md @@ -1,63 +1,96 @@ -## Possible keybindings -| Put in | You will get | -|---------------|----------------| -| `` | F1 | -| `` | F2 | -| `` | F3 | -| `` | F4 | -| `` | F5 | -| `` | F6 | -| `` | F7 | -| `` | F8 | -| `` | F9 | -| `` | F10 | -| `` | F11 | -| `` | F12 | -| `` | Insert | -| `` | Delete | -| `` | Home | -| `` | End | -| `` | Pgup | -| `` | Pgdn | -| `` | ArrowUp | -| `` | ShiftArrowUp | -| `` | ArrowDown | -| `` | ShiftArrowDown | -| `` | ArrowLeft | -| `` | ArrowRight | -| `` | Tab | -| `` | Backtab | -| `` | Enter | -| `` | AltEnter | -| `` | Esc | -| `` | Backspace | -| `` | CtrlSpace | -| `` | CtrlSlash | -| `` | Space | -| `` | CtrlA | -| `` | CtrlB | -| `` | CtrlC | -| `` | CtrlD | -| `` | CtrlE | -| `` | CtrlF | -| `` | CtrlG | -| `` | CtrlJ | -| `` | CtrlK | -| `` | CtrlL | -| `` | CtrlN | -| `` | CtrlO | -| `` | CtrlP | -| `` | CtrlQ | -| `` | CtrlR | -| `` | CtrlS | -| `` | CtrlT | -| `` | CtrlU | -| `` | CtrlV | -| `` | CtrlW | -| `` | CtrlX | -| `` | CtrlY | -| `` | CtrlZ | -| `` | Ctrl4 | -| `` | Ctrl5 | -| `` | Ctrl6 | -| `` | Ctrl8 | +## Custom Keybindings + +A keybinding is one of: + +- A single printable character, e.g. `q`, `?`, `5`. Uppercase letters mean + shift+letter — write `A`, not ``. +- A special key name in angle brackets, e.g. ``, ``, ``. +- A key with modifiers in angle brackets, e.g. `` (Ctrl+C), `` + (Ctrl+Shift+Up). +- The literal string `` to disable a binding. + +### Modifiers + +Prefix a key with one or more modifiers, joined by `-`: + +| Prefix | Long form | Modifier | +| ------ | --------- | ----------------------------------------------------------------------------------------- | +| `c-` | `ctrl-` | Ctrl | +| `a-` | `alt-` | Alt | +| `s-` | `shift-` | Shift | +| `m-` | `meta-` | Depends on terminal; typically ⌘ on macOS or Super/Win key, when the terminal forwards it | + +You can also use `+` instead of `-` as the separator. Modifiers may appear in +any order, and short and long forms can be mixed. The whole binding should be +wrapped in angle brackets when it has any modifiers. The following all express +the same binding: + +- `` +- `` +- `` +- `` + +### Special key names + +| Put in | You will get | +| --------------------------------------- | ------------------- | +| `` – `` | F1 – F12 | +| `` | Insert | +| `` | Delete | +| `` | Home | +| `` | End | +| `` | PageUp | +| `` | PageDown | +| `` | ArrowUp | +| `` | ArrowDown | +| `` | ArrowLeft | +| `` | ArrowRight | +| `` | Tab | +| `` | Shift+Tab | +| `` | Enter | +| `` | Escape | +| `` | Backspace | +| `` | Space | +| ``/`` | Mouse wheel up/down | + +These can be combined with modifiers, e.g. ``, ``, ``. + +### Special characters with modifiers + +`` and `` are keyword forms for `-` and `+` when combined with a +modifier (e.g. `` for Ctrl+`-`). Without modifiers, write `-` and `+` +directly. `` is the keyword for the space character. + +### Combinations that are rejected + +These look reasonable but can't actually be delivered by a terminal: + +- `` (shift alone on a rune) — terminals fold shift into the rune itself, + so shift+a arrives as `A`. Write `A` instead. +- ``, ``, etc. (modifier on an uppercase ASCII letter) — write + `` instead. + +### Terminal compatibility + +Support for combinations of modifiers, and in general keybindings beyond plain +letters and ctrl+letter, require a newer terminal protocol that not all +terminals support. + +Terminals that are known to have good support include: Ghostty, kitty, +WezTerm, foot, Konsole, Alacritty, iTerm2, Windows Terminal. + +The default terminal on macOS (Terminal.app) does not; I recommend to switch to +either Ghostty or iTerm2 as a replacement (or one of the others above). + +On Windows, a popular terminal is the MinTTY console that comes with Git for +Windows; this also doesn't support the newer protocol. The recommended +replacement is Windows Terminal, which is very good these days, and Git Bash +runs just fine in it. + +Inside **tmux** or **screen**, extended keys are stripped unless the multiplexer +is configured to forward them. For tmux 3.2+: + +​` +set -g extended-keys on +set -as terminal-features 'xterm*:extkeys' +​` From 578ee9a31b1ff39c30d363510c42433cb297259f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Apr 2026 16:45:22 +0200 Subject: [PATCH 08/10] Change a-enter keybinding to m-enter on mac, and c-enter elsewhere --- docs-master/Config.md | 4 +++- pkg/app/entry_point.go | 2 +- pkg/config/app_config.go | 3 ++- pkg/config/user_config.go | 17 +++++++++++++++-- schema-master/config.json | 3 ++- 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs-master/Config.md b/docs-master/Config.md index 265ba2311..6d2b41fb4 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -637,7 +637,9 @@ keybinding: confirm: confirmMenu: confirmSuggestion: - confirmInEditor: + + # on Mac + confirmInEditor: confirmInEditor-alt: remove: d new: "n" diff --git a/pkg/app/entry_point.go b/pkg/app/entry_point.go index 3a692ac53..8b1a2a040 100644 --- a/pkg/app/entry_point.go +++ b/pkg/app/entry_point.go @@ -102,7 +102,7 @@ func Start(buildInfo *BuildInfo, integrationTest integrationTypes.IntegrationTes if cliArgs.PrintDefaultConfig { var buf bytes.Buffer encoder := yaml.NewEncoder(&buf) - err := encoder.Encode(config.GetDefaultConfig()) + err := encoder.Encode(config.GetDefaultConfigForPlatform(runtime.GOOS)) if err != nil { log.Fatal(err.Error()) } diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index 038d6c117..27ee38c0b 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "strings" "time" @@ -136,7 +137,7 @@ func findOrCreateConfigDir() (string, error) { } func loadUserConfigWithDefaults(configFiles []*ConfigFile, isGuiInitialized bool) (*UserConfig, error) { - return loadUserConfig(configFiles, GetDefaultConfig(), isGuiInitialized) + return loadUserConfig(configFiles, GetDefaultConfigForPlatform(runtime.GOOS), isGuiInitialized) } func loadUserConfig(configFiles []*ConfigFile, base *UserConfig, isGuiInitialized bool) (*UserConfig, error) { diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 2106e2ad2..2298f7205 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -461,7 +461,7 @@ type KeybindingUniversalConfig struct { Confirm string `yaml:"confirm"` ConfirmMenu string `yaml:"confirmMenu"` ConfirmSuggestion string `yaml:"confirmSuggestion"` - ConfirmInEditor string `yaml:"confirmInEditor"` + ConfirmInEditor string `yaml:"confirmInEditor"` // on Mac ConfirmInEditorAlt string `yaml:"confirmInEditor-alt"` Remove string `yaml:"remove"` New string `yaml:"new"` @@ -766,6 +766,12 @@ type IconProperties struct { } func GetDefaultConfig() *UserConfig { + // This is only for tests; we don't want to use the test runner's host platform in that case, + // but always use the fallback bindings + return GetDefaultConfigForPlatform("") +} + +func GetDefaultConfigForPlatform(platform string) *UserConfig { return &UserConfig{ Gui: GuiConfig{ ScrollHeight: 2, @@ -934,7 +940,7 @@ func GetDefaultConfig() *UserConfig { Confirm: "", ConfirmMenu: "", ConfirmSuggestion: "", - ConfirmInEditor: "", + ConfirmInEditor: platformKeyBinding(platform, map[string]string{"darwin": ""}, ""), ConfirmInEditorAlt: "", Remove: "d", New: "n", @@ -1081,3 +1087,10 @@ func GetDefaultConfig() *UserConfig { }, } } + +func platformKeyBinding(platform string, bindingByPlatform map[string]string, fallback string) string { + if binding, ok := bindingByPlatform[platform]; ok { + return binding + } + return fallback +} diff --git a/schema-master/config.json b/schema-master/config.json index 590a1a9b9..ece571bdf 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -1440,7 +1440,8 @@ }, "confirmInEditor": { "type": "string", - "default": "\u003ca-enter\u003e" + "description": "\u003cm-enter\u003e on Mac", + "default": "\u003cc-enter\u003e" }, "confirmInEditor-alt": { "type": "string", From f5cefc8a58f7fa191a0257ab437c58873ef5eb47 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Apr 2026 19:06:36 +0200 Subject: [PATCH 09/10] Change bindings for moving commits to a-up/down These are the same as many editors (e.g. VS Code) use for moving a line up/down, so they are easy to remember. --- docs-master/Config.md | 4 ++-- docs-master/keybindings/Keybindings_en.md | 4 ++-- docs-master/keybindings/Keybindings_ja.md | 4 ++-- docs-master/keybindings/Keybindings_ko.md | 4 ++-- docs-master/keybindings/Keybindings_nl.md | 4 ++-- docs-master/keybindings/Keybindings_pl.md | 4 ++-- docs-master/keybindings/Keybindings_pt.md | 4 ++-- docs-master/keybindings/Keybindings_ru.md | 4 ++-- docs-master/keybindings/Keybindings_zh-CN.md | 4 ++-- docs-master/keybindings/Keybindings_zh-TW.md | 4 ++-- pkg/config/user_config.go | 4 ++-- schema-master/config.json | 4 ++-- 12 files changed, 24 insertions(+), 24 deletions(-) diff --git a/docs-master/Config.md b/docs-master/Config.md index 6d2b41fb4..a0f531654 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -737,8 +737,8 @@ keybinding: setFixupMessage: c createFixupCommit: F squashAboveCommits: S - moveDownCommit: - moveUpCommit: + moveDownCommit: + moveUpCommit: amendToCommit: A resetCommitAuthor: a pickCommit: p diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 7e80ceb7a..a8b81cdf4 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -98,8 +98,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` p `` | Pick | Mark the selected commit to be picked (when mid-rebase). This means that the commit will be retained upon continuing the rebase. | | `` F `` | Create fixup commit | Create 'fixup!' commit for the selected commit. Later on, you can press `S` on this same commit to apply all above fixup commits. | | `` S `` | Apply fixup commits | Squash all 'fixup!' commits, either above the selected commit, or all in current branch (autosquash). | -| `` `` | Move commit down one | | -| `` `` | Move commit up one | | +| `` `` | Move commit down one | | +| `` `` | Move commit up one | | | `` V `` | Paste (cherry-pick) | | | `` B `` | Mark as base commit for rebase | Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command. | | `` A `` | Amend | Amend commit with staged changes. If the selected commit is the HEAD commit, this will perform `git commit --amend`. Otherwise the commit will be amended via a rebase. | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index bbb53a74b..6be4f8d0a 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -78,8 +78,8 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | `` p `` | ピック | 選択したコミットをピックするようにマークします(リベース中)。これは、リベースを続行すると、コミットが保持されることを意味します。 | | `` F `` | fixupコミットを作成 | 選択したコミットに対する「fixup!」コミットを作成します。fixupコミットは、選択したコミットの修正用コミットです。後で、同じコミットで `S` を押すと、上記のすべてのfixupコミットが適用されます。 | | `` S `` | fixupコミットを適用 | すべての「fixup!」コミットを、選択したコミットの上部または現在のブランチ内のすべてをスカッシュします(autosquash)。 | -| `` `` | コミットを1つ下に移動 | | -| `` `` | コミットを1つ上に移動 | | +| `` `` | コミットを1つ下に移動 | | +| `` `` | コミットを1つ上に移動 | | | `` V `` | ペースト(チェリーピック) | | | `` B `` | リベース用のベースコミットとしてマーク | 次のリベース用のベースコミットを選択します。ブランチにリベースするとき、ベースコミットより上のコミットのみが持ち込まれます。これは `git rebase --onto` コマンドを使用します。 | | `` A `` | 修正 | ステージされた変更でコミットを修正します。選択したコミットがHEADコミットの場合、これは `git commit --amend` を実行します。それ以外の場合、コミットはリベースを通じて修正されます。 | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index 0f18930f6..359dc6cb0 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -310,8 +310,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` p `` | Pick | Pick commit (when mid-rebase) | | `` F `` | Create fixup commit | Create fixup commit for this commit | | `` S `` | Apply fixup commits | Squash all 'fixup!' commits above selected commit (autosquash) | -| `` `` | 커밋을 1개 아래로 이동 | | -| `` `` | 커밋을 1개 위로 이동 | | +| `` `` | 커밋을 1개 아래로 이동 | | +| `` `` | 커밋을 1개 위로 이동 | | | `` V `` | 커밋을 붙여넣기 (cherry-pick) | | | `` B `` | Mark as base commit for rebase | Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command. | | `` A `` | Amend | Amend commit with staged changes | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index b2d0d8c87..73e929f73 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -170,8 +170,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` p `` | Pick | Kies commit (wanneer midden in rebase) | | `` F `` | Creëer fixup commit | Creëer fixup commit | | `` S `` | Apply fixup commits | Squash bovenstaande commits | -| `` `` | Verplaats commit 1 naar beneden | | -| `` `` | Verplaats commit 1 naar boven | | +| `` `` | Verplaats commit 1 naar beneden | | +| `` `` | Verplaats commit 1 naar boven | | | `` V `` | Plak commits (cherry-pick) | | | `` B `` | Mark as base commit for rebase | Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command. | | `` A `` | Amend | Wijzig commit met staged veranderingen | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index 672c0e3df..23cd3b21f 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -71,8 +71,8 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | `` p `` | Wybierz | Oznacz wybrany commit do wybrania (podczas rebazowania). Oznacza to, że commit zostanie zachowany po kontynuacji rebazowania. | | `` F `` | Utwórz commit fixup | Utwórz commit 'fixup!' dla wybranego commita. Później możesz nacisnąć `S` na tym samym commicie, aby zastosować wszystkie powyższe commity fixup. | | `` S `` | Zastosuj commity fixup | Scal wszystkie commity 'fixup!', albo powyżej wybranego commita, albo wszystkie w bieżącej gałęzi (autosquash). | -| `` `` | Przesuń commit w dół | | -| `` `` | Przesuń commit w górę | | +| `` `` | Przesuń commit w dół | | +| `` `` | Przesuń commit w górę | | | `` V `` | Wklej (cherry-pick) | | | `` B `` | Oznacz jako bazowy commit dla rebase | Wybierz bazowy commit dla następnego rebase. Kiedy robisz rebase na branch, tylko commity powyżej bazowego commita zostaną przeniesione. Używa to polecenia `git rebase --onto`. | | `` A `` | Popraw | Popraw commit ze zmianami zatwierdzonymi. Jeśli wybrany commit jest commit HEAD, to wykona `git commit --amend`. W przeciwnym razie commit zostanie poprawiony za pomocą rebazowania. | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 9bb9ab097..7cf840ef8 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -174,8 +174,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` p `` | Escolher | Marque o commit selecionado para ser escolhido (quando meados da base). Isso significa que o commit será mantido ao continuar o rebase. | | `` F `` | Criar commit de correção | Crie o commit 'correção!' para o commit selecionado. Mais tarde, você pode pressionar `S` neste mesmo commit para aplicar todas os commits de correção acima. | | `` S `` | Aplicar commits de correções | Aplicar Squash all 'correção!', seja acima do commit selecionado, ou tudo no branch atual (autosquash). | -| `` `` | Mover commit um para baixo | | -| `` `` | Mover o commit um para cima | | +| `` `` | Mover commit um para baixo | | +| `` `` | Mover o commit um para cima | | | `` V `` | Colar (cherry-pick) | | | `` B `` | Mark as base commit for rebase | Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command. | | `` A `` | Modificar | Alterar o commit com mudanças em sted. Se o commit selecionado for o commit HEAD, ele executará o `git commit --amend`. Caso contrário, o compromisso será alterado por meio de uma base de apoio. | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index bd738f3ed..b1d9c88ab 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -180,8 +180,8 @@ _Связки клавиш_ | `` p `` | Pick | Выбрать коммит (в середине перебазирования) | | `` F `` | Создать fixup коммит | Создать fixup коммит для этого коммита | | `` S `` | Apply fixup commits | Объединить все 'fixup!' коммиты выше в выбранный коммит (автосохранение) | -| `` `` | Переместить коммит вниз на один | | -| `` `` | Переместить коммит вверх на один | | +| `` `` | Переместить коммит вниз на один | | +| `` `` | Переместить коммит вверх на один | | | `` V `` | Вставить отобранные коммиты (cherry-pick) | | | `` B `` | Mark as base commit for rebase | Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command. | | `` A `` | Amend | Править последний коммит с проиндексированными изменениями | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index b16dff18c..239015dbb 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -135,8 +135,8 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | `` p `` | 拣选(Pick) | 标记选中的提交为 picked(变基过程中)。这意味该提交将在后续的变基中保留。 | | `` F `` | 为此提交创建修正 | 创建修正提交 | | `` S `` | 应用该修复提交 | 压缩所选提交之上或当前分支的所有 “fixup!” 提交(自动压缩)。 | -| `` `` | 下移提交 | | -| `` `` | 上移提交 | | +| `` `` | 下移提交 | | +| `` `` | 上移提交 | | | `` V `` | 粘贴提交(拣选) | | | `` B `` | 标记一个主提交用于变基 | 选择下一次变基的主提交。当您变基到一个分支时,只有高于主提交的提交才会被引入。这使用“git rebase --onto”命令。 | | `` A `` | 修补(Amend) | 用已暂存的变更来修补提交 | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index 3d637160b..88d2b94f3 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -194,8 +194,8 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | `` p `` | 挑選 | 挑選提交 (於變基過程中) | | `` F `` | 建立修復提交 | 為此提交建立修復提交 | | `` S `` | 壓縮上方所有「fixup」提交(自動壓縮) | 是否壓縮上方 {{.commit}} 所有「fixup」提交? | -| `` `` | 向下移動提交 | | -| `` `` | 向上移動提交 | | +| `` `` | 向下移動提交 | | +| `` `` | 向上移動提交 | | | `` V `` | 貼上提交 (揀選) | | | `` B `` | 為了變基已標注提交為基準提交 | 請為了下一次變基選擇一項基準提交;此將執行 `git rebase --onto`。 | | `` A `` | 修改 | 使用已預存的更改修正提交 | diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 2298f7205..72c6bb742 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -1039,8 +1039,8 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { SetFixupMessage: "c", CreateFixupCommit: "F", SquashAboveCommits: "S", - MoveDownCommit: "", - MoveUpCommit: "", + MoveDownCommit: "", + MoveUpCommit: "", AmendToCommit: "A", ResetCommitAuthor: "a", PickCommit: "p", diff --git a/schema-master/config.json b/schema-master/config.json index ece571bdf..757a23e27 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -996,11 +996,11 @@ }, "moveDownCommit": { "type": "string", - "default": "\u003cc-j\u003e" + "default": "\u003ca-down\u003e" }, "moveUpCommit": { "type": "string", - "default": "\u003cc-k\u003e" + "default": "\u003ca-up\u003e" }, "amendToCommit": { "type": "string", From 8a6c97a920d9d9d259c2aea6642a68d16eebcf30 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 30 Apr 2026 21:48:13 +0200 Subject: [PATCH 10/10] Add breaking changes note for the changed bindings --- pkg/i18n/english.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 002adb114..479cfe2f9 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -2263,6 +2263,15 @@ keybinding: redo: - The 'git.paging.useConfig' option has been removed. If you were relying on it to configure your pager, you'll have to explicitly set the pager again using the 'git.paging.pager' option. +`, + "0.62.0": `- The default keybindings for moving commits up and down have changed from ctrl-k/ctrl-j to alt-up/alt-down; this is mostly for personal preference, I find them easier to remember, and they are nicely similar to moving a line of code up and down in many code editors. Also, the default binding for submitting a commit from the commit description editor has changed from alt-enter to command-enter on Mac, or ctrl-enter on Linux and Windows; these are the same bindings that are used in many multi-line edit field situations, e.g. in GitHub comments. Unfortunately these are not supported by all terminals; see https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md#terminal-compatibility for more on that. If you want to revert these changes, you can do so by adding the following to your config: + +keybinding: + commits: + moveDownCommit: + moveUpCommit: + universal: + confirmInEditor: `, }, }