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).
This commit is contained in:
Stefan Haller 2026-04-02 18:04:07 +02:00
parent 30b619b3db
commit 22169e22ff
21 changed files with 952 additions and 221 deletions

View file

@ -221,8 +221,8 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` mouse wheel down (fn+up) `` | Scroll down | | | `` <mouse wheel down> (fn+up) `` | Scroll down | |
| `` mouse wheel up (fn+down) `` | Scroll up | | | `` <mouse wheel up> (fn+down) `` | Scroll up | |
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). | | `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | | | `` <esc> `` | Exit back to side panel | |
| `` / `` | Search the current view by text | | | `` / `` | Search the current view by text | |

View file

@ -304,8 +304,8 @@ _凡例`c-b` はctrl+b、`a-b` はalt+b、`B` はshift+bを意味
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` mouse wheel down (fn+up) `` | 下にスクロール | | | `` <mouse wheel down> (fn+up) `` | 下にスクロール | |
| `` mouse wheel up (fn+down) `` | 上にスクロール | | | `` <mouse wheel up> (fn+down) `` | 上にスクロール | |
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 | | `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` <esc> `` | サイドパネルに戻る | | | `` <esc> `` | サイドパネルに戻る | |
| `` / `` | 現在のビューをテキストで検索 | | | `` / `` | 現在のビューをテキストで検索 | |

View file

@ -160,8 +160,8 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` mouse wheel down (fn+up) `` | 아래로 스크롤 | | | `` <mouse wheel down> (fn+up) `` | 아래로 스크롤 | |
| `` mouse wheel up (fn+down) `` | 위로 스크롤 | | | `` <mouse wheel up> (fn+down) `` | 위로 스크롤 | |
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). | | `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | | | `` <esc> `` | Exit back to side panel | |
| `` / `` | 검색 시작 | | | `` / `` | 검색 시작 | |

View file

@ -229,8 +229,8 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` mouse wheel down (fn+up) `` | Scroll omlaag | | | `` <mouse wheel down> (fn+up) `` | Scroll omlaag | |
| `` mouse wheel up (fn+down) `` | Scroll omhoog | | | `` <mouse wheel up> (fn+down) `` | Scroll omhoog | |
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). | | `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | | | `` <esc> `` | Exit back to side panel | |
| `` / `` | Start met zoeken | | | `` / `` | Start met zoeken | |

View file

@ -179,8 +179,8 @@ _Legenda: `<c-b>` oznacza ctrl+b, `<a-b>` oznacza alt+b, `B` oznacza shift+b_
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` mouse wheel down (fn+up) `` | Przewiń w dół | | | `` <mouse wheel down> (fn+up) `` | Przewiń w dół | |
| `` mouse wheel up (fn+down) `` | Przewiń w górę | | | `` <mouse wheel up> (fn+down) `` | Przewiń w górę | |
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). | | `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
| `` <esc> `` | Exit back to side panel | | | `` <esc> `` | Exit back to side panel | |
| `` / `` | Szukaj w bieżącym widoku po tekście | | | `` / `` | Szukaj w bieżącym widoku po tekście | |

View file

@ -233,8 +233,8 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` mouse wheel down (fn+up) `` | Rolar para baixo | | | `` <mouse wheel down> (fn+up) `` | Rolar para baixo | |
| `` mouse wheel up (fn+down) `` | Rolar para cima | | | `` <mouse wheel up> (fn+down) `` | Rolar para cima | |
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). | | `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
| `` <esc> `` | Exit back to side panel | | | `` <esc> `` | Exit back to side panel | |
| `` / `` | Pesquisar na visualização atual por texto | | | `` / `` | Pesquisar na visualização atual por texto | |

View file

@ -104,8 +104,8 @@ _Связки клавиш_
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` mouse wheel down (fn+up) `` | Прокрутить вниз | | | `` <mouse wheel down> (fn+up) `` | Прокрутить вниз | |
| `` mouse wheel up (fn+down) `` | Прокрутить вверх | | | `` <mouse wheel up> (fn+down) `` | Прокрутить вверх | |
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). | | `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | | | `` <esc> `` | Exit back to side panel | |
| `` / `` | Найти | | | `` / `` | Найти | |

View file

@ -332,8 +332,8 @@ _图例`<c-b>` 意味着ctrl+b, `<a-b>意味着Alt+b, `B` 意味着shift+b_
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` mouse wheel down (fn+up) `` | 向下滚动 | | | `` <mouse wheel down> (fn+up) `` | 向下滚动 | |
| `` mouse wheel up (fn+down) `` | 向上滚动 | | | `` <mouse wheel up> (fn+down) `` | 向上滚动 | |
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) | | `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` <esc> `` | 退出回到侧边面板 | | | `` <esc> `` | 退出回到侧边面板 | |
| `` / `` | 开始搜索 | | | `` / `` | 开始搜索 | |

View file

@ -80,8 +80,8 @@ _說明`<c-b>` 表示 CtrlB、`<a-b>` 表示 AltB`B`表示 ShiftB
| Key | Action | Info | | Key | Action | Info |
|-----|--------|-------------| |-----|--------|-------------|
| `` mouse wheel down (fn+up) `` | 向下捲動 | | | `` <mouse wheel down> (fn+up) `` | 向下捲動 | |
| `` mouse wheel up (fn+down) `` | 向上捲動 | | | `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). | | `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | | | `` <esc> `` | Exit back to side panel | |
| `` / `` | 搜尋 | | | `` / `` | 搜尋 | |

View file

@ -14,61 +14,33 @@ import (
// docs/keybindings/Custom_Keybindings.md as well // docs/keybindings/Custom_Keybindings.md as well
var labelByKey = map[gocui.KeyName]string{ var labelByKey = map[gocui.KeyName]string{
gocui.KeyF1: "<f1>", gocui.KeyF1: "f1",
gocui.KeyF2: "<f2>", gocui.KeyF2: "f2",
gocui.KeyF3: "<f3>", gocui.KeyF3: "f3",
gocui.KeyF4: "<f4>", gocui.KeyF4: "f4",
gocui.KeyF5: "<f5>", gocui.KeyF5: "f5",
gocui.KeyF6: "<f6>", gocui.KeyF6: "f6",
gocui.KeyF7: "<f7>", gocui.KeyF7: "f7",
gocui.KeyF8: "<f8>", gocui.KeyF8: "f8",
gocui.KeyF9: "<f9>", gocui.KeyF9: "f9",
gocui.KeyF10: "<f10>", gocui.KeyF10: "f10",
gocui.KeyF11: "<f11>", gocui.KeyF11: "f11",
gocui.KeyF12: "<f12>", gocui.KeyF12: "f12",
gocui.KeyInsert: "<insert>", gocui.KeyInsert: "insert",
gocui.KeyDelete: "<delete>", gocui.KeyDelete: "delete",
gocui.KeyHome: "<home>", gocui.KeyHome: "home",
gocui.KeyEnd: "<end>", gocui.KeyEnd: "end",
gocui.KeyPgup: "<pgup>", gocui.KeyPgup: "pgup",
gocui.KeyPgdn: "<pgdown>", gocui.KeyPgdn: "pgdown",
gocui.KeyArrowUp: "<up>", gocui.KeyArrowUp: "up",
gocui.KeyShiftArrowUp: "<s-up>", gocui.KeyArrowDown: "down",
gocui.KeyArrowDown: "<down>", gocui.KeyArrowLeft: "left",
gocui.KeyShiftArrowDown: "<s-down>", gocui.KeyArrowRight: "right",
gocui.KeyArrowLeft: "<left>", gocui.KeyTab: "tab",
gocui.KeyArrowRight: "<right>", gocui.KeyBacktab: "backtab",
gocui.KeyTab: "<tab>", // <c-i> gocui.KeyEnter: "enter",
gocui.KeyBacktab: "<backtab>", gocui.KeyEsc: "esc",
gocui.KeyEnter: "<enter>", // <c-m> gocui.KeyBackspace: "backspace",
gocui.KeyAltEnter: "<a-enter>",
gocui.KeyEsc: "<esc>", // <c-[>, <c-3>
gocui.KeyBackspace: "<backspace>", // <c-h>
gocui.KeySpace: "<space>",
gocui.KeyCtrlA: "<c-a>",
gocui.KeyCtrlB: "<c-b>",
gocui.KeyCtrlC: "<c-c>",
gocui.KeyCtrlD: "<c-d>",
gocui.KeyCtrlE: "<c-e>",
gocui.KeyCtrlF: "<c-f>",
gocui.KeyCtrlG: "<c-g>",
gocui.KeyCtrlJ: "<c-j>",
gocui.KeyCtrlK: "<c-k>",
gocui.KeyCtrlL: "<c-l>",
gocui.KeyCtrlN: "<c-n>",
gocui.KeyCtrlO: "<c-o>",
gocui.KeyCtrlP: "<c-p>",
gocui.KeyCtrlQ: "<c-q>",
gocui.KeyCtrlR: "<c-r>",
gocui.KeyCtrlS: "<c-s>",
gocui.KeyCtrlT: "<c-t>",
gocui.KeyCtrlU: "<c-u>",
gocui.KeyCtrlV: "<c-v>",
gocui.KeyCtrlW: "<c-w>",
gocui.KeyCtrlX: "<c-x>",
gocui.KeyCtrlY: "<c-y>",
gocui.KeyCtrlZ: "<c-z>",
gocui.KeyCtrl8: "<c-8>",
gocui.MouseWheelUp: "mouse wheel up", gocui.MouseWheelUp: "mouse wheel up",
gocui.MouseWheelDown: "mouse wheel down", gocui.MouseWheelDown: "mouse wheel down",
} }
@ -80,16 +52,44 @@ func LabelForKey(key gocui.Key) string {
return "" return ""
} }
if key.KeyName() == gocui.KeyName(tcell.KeyRune) { label := ""
return key.Str() 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) {
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()] value, ok := labelByKey[key.KeyName()]
if ok { if ok {
return value label += value
} else {
label += "unknown"
}
} }
return "unknown" if utf8.RuneCountInString(label) > 1 {
label = "<" + label + ">"
}
return label
} }
func KeyFromLabel(label string) (gocui.Key, bool) { func KeyFromLabel(label string) (gocui.Key, bool) {
@ -97,16 +97,99 @@ func KeyFromLabel(label string) (gocui.Key, bool) {
return gocui.Key{}, true return gocui.Key{}, true
} }
runeCount := utf8.RuneCountInString(label) if strings.HasPrefix(label, "<") && strings.HasSuffix(label, ">") {
if runeCount > 1 { label = label[1 : len(label)-1]
keyName, ok := keyByLabel[strings.ToLower(label)]
if !ok {
return gocui.Key{}, false
}
return gocui.NewKeyName(keyName), true
} }
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 `<c-->` and `<c++>`,
// neither of which we emit (we use `<c-minus>` and `<c-+>`).
if label == "-" || label == "+" {
return gocui.NewKeyStrMod(label, mod), true
}
sepIdx := strings.IndexAny(label, "-+")
if sepIdx == -1 {
break
}
modStr, remainder := label[:sepIdx], label[sepIdx+1:]
label = remainder
switch modStr {
case "s", "shift":
if (mod & gocui.ModShift) != 0 {
return gocui.Key{}, false
}
mod |= gocui.ModShift
case "c", "ctrl":
if (mod & gocui.ModCtrl) != 0 {
return gocui.Key{}, false
}
mod |= gocui.ModCtrl
case "a", "alt":
if (mod & gocui.ModAlt) != 0 {
return gocui.Key{}, false
}
mod |= gocui.ModAlt
case "m", "meta":
if (mod & gocui.ModMeta) != 0 {
return gocui.Key{}, false
}
mod |= gocui.ModMeta
default:
return gocui.Key{}, false
}
}
if label == "space" {
return gocui.NewKeyStrMod(" ", mod), true
}
if label == "minus" {
if mod == gocui.ModShift {
return gocui.Key{}, false
}
return gocui.NewKeyStrMod("-", mod), true
}
if label == "plus" {
if mod == gocui.ModShift {
return gocui.Key{}, false
}
return gocui.NewKeyStrMod("+", mod), true
}
if keyName, ok := keyByLabel[label]; ok {
return gocui.NewKey(keyName, "", mod), true
}
runeCount := utf8.RuneCountInString(label)
if runeCount != 1 {
return gocui.Key{}, false
}
// Shift on a bare rune is invalid: terminals fold shift into the rune
// itself (shift+a arrives as "A"), so the binding could never fire.
// Space is exempt and handled above; combined with other modifiers,
// shift is fine because the terminal can't fold it into the rune then.
if mod == gocui.ModShift {
return gocui.Key{}, false
}
// An ASCII uppercase letter with any modifier is invalid. Ctrl+letter
// events always arrive with a lowercase rune — control codes have no
// case distinction (the terminal sends the same byte for ctrl+a and
// ctrl+A), and CSI-u protocols report the unshifted codepoint with
// shift as a separate modifier (alt+shift+a → rune='a' mod=Alt|Shift).
// Users should write <c-s-a> rather than <c-A>.
if mod != gocui.ModNone && len(label) == 1 && label[0] >= 'A' && label[0] <= 'Z' {
return gocui.Key{}, false
}
return gocui.NewKeyStrMod(label, mod), true
} }
func isValidKeybindingKey(key string) bool { func isValidKeybindingKey(key string) bool {

686
pkg/config/keynames_test.go Normal file
View file

@ -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: "<disabled> returns unset key",
label: "<disabled>",
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: "<f12>",
expectedKey: gocui.NewKey(gocui.KeyF12, "", gocui.ModNone),
expectedOk: true,
},
{
name: "arrow key",
label: "<up>",
expectedKey: gocui.NewKey(gocui.KeyArrowUp, "", gocui.ModNone),
expectedOk: true,
},
{
name: "tab",
label: "<tab>",
expectedKey: gocui.NewKey(gocui.KeyTab, "", gocui.ModNone),
expectedOk: true,
},
{
name: "enter",
label: "<enter>",
expectedKey: gocui.NewKey(gocui.KeyEnter, "", gocui.ModNone),
expectedOk: true,
},
{
name: "esc",
label: "<esc>",
expectedKey: gocui.NewKey(gocui.KeyEsc, "", gocui.ModNone),
expectedOk: true,
},
{
name: "backspace",
label: "<backspace>",
expectedKey: gocui.NewKey(gocui.KeyBackspace, "", gocui.ModNone),
expectedOk: true,
},
{
name: "pgup",
label: "<pgup>",
expectedKey: gocui.NewKey(gocui.KeyPgup, "", gocui.ModNone),
expectedOk: true,
},
{
name: "pgdown",
label: "<pgdown>",
expectedKey: gocui.NewKey(gocui.KeyPgdn, "", gocui.ModNone),
expectedOk: true,
},
{
name: "mouse wheel up",
label: "<mouse wheel up>",
expectedKey: gocui.NewKey(gocui.MouseWheelUp, "", gocui.ModNone),
expectedOk: true,
},
// Space
{
name: "space keyword maps to space rune",
label: "<space>",
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: "<c-space>",
expectedKey: gocui.NewKeyStrMod(" ", gocui.ModCtrl),
expectedOk: true,
},
// Minus
{
name: "minus keyword maps to dash rune",
label: "<minus>",
expectedKey: gocui.NewKeyStrMod("-", gocui.ModNone),
expectedOk: true,
},
{
name: "ctrl+minus via keyword",
label: "<c-minus>",
expectedKey: gocui.NewKeyStrMod("-", gocui.ModCtrl),
expectedOk: true,
},
{
name: "ctrl+minus via lenient dash form",
label: "<c-->",
expectedKey: gocui.NewKeyStrMod("-", gocui.ModCtrl),
expectedOk: true,
},
{
name: "alt+ctrl+minus via lenient dash form",
label: "<a-c-->",
expectedKey: gocui.NewKeyStrMod("-", gocui.ModAlt|gocui.ModCtrl),
expectedOk: true,
},
// Plus
{
name: "plus keyword maps to plus rune",
label: "<plus>",
expectedKey: gocui.NewKeyStrMod("+", gocui.ModNone),
expectedOk: true,
},
{
name: "ctrl+plus via keyword",
label: "<c-plus>",
expectedKey: gocui.NewKeyStrMod("+", gocui.ModCtrl),
expectedOk: true,
},
{
name: "ctrl+plus via long keyword and plus separator",
label: "<ctrl+plus>",
expectedKey: gocui.NewKeyStrMod("+", gocui.ModCtrl),
expectedOk: true,
},
{
name: "alt+shift+plus via keyword",
label: "<a-s-plus>",
expectedKey: gocui.NewKeyStrMod("+", gocui.ModAlt|gocui.ModShift),
expectedOk: true,
},
{
name: "shift alone on plus is rejected",
label: "<s-plus>",
expectedKey: gocui.Key{},
expectedOk: false,
},
// Modifiers with runes
{
name: "ctrl+letter",
label: "<c-a>",
expectedKey: gocui.NewKeyStrMod("a", gocui.ModCtrl),
expectedOk: true,
},
{
name: "alt+letter",
label: "<a-x>",
expectedKey: gocui.NewKeyStrMod("x", gocui.ModAlt),
expectedOk: true,
},
{
name: "meta+letter",
label: "<m-z>",
expectedKey: gocui.NewKeyStrMod("z", gocui.ModMeta),
expectedOk: true,
},
// Long modifier names are accepted as synonyms for the short forms.
{
name: "ctrl long form",
label: "<ctrl-a>",
expectedKey: gocui.NewKeyStrMod("a", gocui.ModCtrl),
expectedOk: true,
},
{
name: "alt long form",
label: "<alt-x>",
expectedKey: gocui.NewKeyStrMod("x", gocui.ModAlt),
expectedOk: true,
},
{
name: "meta long form",
label: "<meta-z>",
expectedKey: gocui.NewKeyStrMod("z", gocui.ModMeta),
expectedOk: true,
},
{
name: "shift long form combined with ctrl",
label: "<shift-ctrl-a>",
expectedKey: gocui.NewKeyStrMod("a", gocui.ModShift|gocui.ModCtrl),
expectedOk: true,
},
{
name: "long forms work with special keys",
label: "<ctrl-up>",
expectedKey: gocui.NewKey(gocui.KeyArrowUp, "", gocui.ModCtrl),
expectedOk: true,
},
{
name: "short and long forms can be mixed",
label: "<ctrl-s-up>",
expectedKey: gocui.NewKey(gocui.KeyArrowUp, "", gocui.ModCtrl|gocui.ModShift),
expectedOk: true,
},
{
name: "duplicate via mixed short and long form is rejected",
label: "<shift-s-a>",
expectedKey: gocui.Key{},
expectedOk: false,
},
{
name: "unknown long modifier is rejected",
label: "<control-a>",
expectedKey: gocui.Key{},
expectedOk: false,
},
// Plus is accepted as an alternative modifier separator.
{
name: "plus separator with short form",
label: "<c+a>",
expectedKey: gocui.NewKeyStrMod("a", gocui.ModCtrl),
expectedOk: true,
},
{
name: "plus separator with long form",
label: "<ctrl+alt+a>",
expectedKey: gocui.NewKeyStrMod("a", gocui.ModCtrl|gocui.ModAlt),
expectedOk: true,
},
{
name: "plus separator with special key",
label: "<ctrl+up>",
expectedKey: gocui.NewKey(gocui.KeyArrowUp, "", gocui.ModCtrl),
expectedOk: true,
},
{
name: "mixed plus and dash separators",
label: "<ctrl+shift-up>",
expectedKey: gocui.NewKey(gocui.KeyArrowUp, "", gocui.ModCtrl|gocui.ModShift),
expectedOk: true,
},
{
name: "duplicate detection works across separators",
label: "<c+c-a>",
expectedKey: gocui.Key{},
expectedOk: false,
},
{
name: "ctrl+plus rune via plus separator",
label: "<c++>",
expectedKey: gocui.NewKeyStrMod("+", gocui.ModCtrl),
expectedOk: true,
},
{
name: "ctrl+dash rune via plus separator",
label: "<c+->",
expectedKey: gocui.NewKeyStrMod("-", gocui.ModCtrl),
expectedOk: true,
},
{
name: "ctrl+plus rune via dash separator",
label: "<c-+>",
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: "<s-q>",
expectedKey: gocui.Key{},
expectedOk: false,
},
{
name: "shift alone on uppercase letter is rejected",
label: "<s-A>",
expectedKey: gocui.Key{},
expectedOk: false,
},
{
name: "shift alone on minus is rejected",
label: "<s-minus>",
expectedKey: gocui.Key{},
expectedOk: false,
},
{
name: "shift on space is allowed (rune does not change)",
label: "<s-space>",
expectedKey: gocui.NewKeyStrMod(" ", gocui.ModShift),
expectedOk: true,
},
{
name: "shift combined with ctrl on a letter is allowed",
label: "<c-s-x>",
expectedKey: gocui.NewKeyStrMod("x", gocui.ModCtrl|gocui.ModShift),
expectedOk: true,
},
{
name: "shift combined with alt on minus is allowed",
label: "<a-s-minus>",
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: "<c-A>",
expectedKey: gocui.Key{},
expectedOk: false,
},
{
name: "alt+uppercase letter is rejected",
label: "<a-A>",
expectedKey: gocui.Key{},
expectedOk: false,
},
{
name: "meta+uppercase letter is rejected",
label: "<m-A>",
expectedKey: gocui.Key{},
expectedOk: false,
},
{
name: "combined modifier on uppercase letter is rejected",
label: "<c-a-A>",
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: "<c-1>",
expectedKey: gocui.NewKeyStrMod("1", gocui.ModCtrl),
expectedOk: true,
},
{
name: "modifier on non-ASCII uppercase letter is allowed",
label: "<a-Ñ>",
expectedKey: gocui.NewKeyStrMod("Ñ", gocui.ModAlt),
expectedOk: true,
},
// Modifiers with special keys
{
name: "ctrl+enter",
label: "<c-enter>",
expectedKey: gocui.NewKey(gocui.KeyEnter, "", gocui.ModCtrl),
expectedOk: true,
},
{
name: "alt+up",
label: "<a-up>",
expectedKey: gocui.NewKey(gocui.KeyArrowUp, "", gocui.ModAlt),
expectedOk: true,
},
{
name: "shift+f1",
label: "<s-f1>",
expectedKey: gocui.NewKey(gocui.KeyF1, "", gocui.ModShift),
expectedOk: true,
},
{
name: "meta+enter",
label: "<m-enter>",
expectedKey: gocui.NewKey(gocui.KeyEnter, "", gocui.ModMeta),
expectedOk: true,
},
// Combined modifiers
{
name: "ctrl+alt+letter",
label: "<c-a-x>",
expectedKey: gocui.NewKeyStrMod("x", gocui.ModCtrl|gocui.ModAlt),
expectedOk: true,
},
{
name: "all four modifiers on a letter",
label: "<s-c-a-m-x>",
expectedKey: gocui.NewKeyStrMod("x", gocui.ModShift|gocui.ModCtrl|gocui.ModAlt|gocui.ModMeta),
expectedOk: true,
},
{
name: "ctrl+shift+arrow key",
label: "<c-s-up>",
expectedKey: gocui.NewKey(gocui.KeyArrowUp, "", gocui.ModCtrl|gocui.ModShift),
expectedOk: true,
},
// Bracket handling
{
name: "single rune wrapped in brackets is unwrapped",
label: "<a>",
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: "<nope>",
expectedKey: gocui.Key{},
expectedOk: false,
},
{
name: "unknown modifier letter",
label: "<x-a>",
expectedKey: gocui.Key{},
expectedOk: false,
},
{
name: "uppercase modifier is not accepted",
label: "<C-a>",
expectedKey: gocui.Key{},
expectedOk: false,
},
{
name: "duplicate ctrl modifier",
label: "<c-c-a>",
expectedKey: gocui.Key{},
expectedOk: false,
},
{
name: "duplicate shift modifier",
label: "<s-s-a>",
expectedKey: gocui.Key{},
expectedOk: false,
},
{
name: "duplicate alt modifier",
label: "<a-a-x>",
expectedKey: gocui.Key{},
expectedOk: false,
},
{
name: "duplicate meta modifier",
label: "<m-m-x>",
expectedKey: gocui.Key{},
expectedOk: false,
},
{
name: "trailing modifier with no key",
label: "<c->",
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: "<c-nope>",
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), "<space>"},
{"ctrl+dash uses minus keyword", gocui.NewKeyStrMod("-", gocui.ModCtrl), "<c-minus>"},
{"alt+dash uses minus keyword", gocui.NewKeyStrMod("-", gocui.ModAlt), "<a-minus>"},
{"plain plus uses literal", gocui.NewKeyStrMod("+", gocui.ModNone), "+"},
{"ctrl+plus uses plus keyword", gocui.NewKeyStrMod("+", gocui.ModCtrl), "<c-plus>"},
{"alt+plus uses plus keyword", gocui.NewKeyStrMod("+", gocui.ModAlt), "<a-plus>"},
{"ctrl+space", gocui.NewKeyStrMod(" ", gocui.ModCtrl), "<c-space>"},
// Single modifier on a rune
{"ctrl+letter", gocui.NewKeyStrMod("a", gocui.ModCtrl), "<c-a>"},
{"alt+letter", gocui.NewKeyStrMod("x", gocui.ModAlt), "<a-x>"},
{"meta+letter", gocui.NewKeyStrMod("z", gocui.ModMeta), "<m-z>"},
{"shift+space", gocui.NewKeyStrMod(" ", gocui.ModShift), "<s-space>"},
// Modifier ordering — canonical output is c-, a-, s-, m-
{"ctrl+alt orders c before a", gocui.NewKeyStrMod("x", gocui.ModCtrl|gocui.ModAlt), "<c-a-x>"},
{"shift+ctrl orders c before s", gocui.NewKeyStrMod("x", gocui.ModShift|gocui.ModCtrl), "<c-s-x>"},
{"meta+shift orders s before m", gocui.NewKeyStrMod("x", gocui.ModMeta|gocui.ModShift), "<s-m-x>"},
{
"all four modifiers ordered c-a-s-m",
gocui.NewKeyStrMod("x", gocui.ModCtrl|gocui.ModAlt|gocui.ModShift|gocui.ModMeta),
"<c-a-s-m-x>",
},
// Special keys (always wrapped, even unmodified)
{"f1", gocui.NewKey(gocui.KeyF1, "", gocui.ModNone), "<f1>"},
{"f12", gocui.NewKey(gocui.KeyF12, "", gocui.ModNone), "<f12>"},
{"insert", gocui.NewKey(gocui.KeyInsert, "", gocui.ModNone), "<insert>"},
{"delete", gocui.NewKey(gocui.KeyDelete, "", gocui.ModNone), "<delete>"},
{"home", gocui.NewKey(gocui.KeyHome, "", gocui.ModNone), "<home>"},
{"end", gocui.NewKey(gocui.KeyEnd, "", gocui.ModNone), "<end>"},
{"pgup", gocui.NewKey(gocui.KeyPgup, "", gocui.ModNone), "<pgup>"},
{"pgdown", gocui.NewKey(gocui.KeyPgdn, "", gocui.ModNone), "<pgdown>"},
{"arrow up", gocui.NewKey(gocui.KeyArrowUp, "", gocui.ModNone), "<up>"},
{"arrow down", gocui.NewKey(gocui.KeyArrowDown, "", gocui.ModNone), "<down>"},
{"arrow left", gocui.NewKey(gocui.KeyArrowLeft, "", gocui.ModNone), "<left>"},
{"arrow right", gocui.NewKey(gocui.KeyArrowRight, "", gocui.ModNone), "<right>"},
{"tab", gocui.NewKey(gocui.KeyTab, "", gocui.ModNone), "<tab>"},
{"backtab", gocui.NewKey(gocui.KeyBacktab, "", gocui.ModNone), "<backtab>"},
{"enter", gocui.NewKey(gocui.KeyEnter, "", gocui.ModNone), "<enter>"},
{"esc", gocui.NewKey(gocui.KeyEsc, "", gocui.ModNone), "<esc>"},
{"backspace", gocui.NewKey(gocui.KeyBackspace, "", gocui.ModNone), "<backspace>"},
{"mouse wheel up", gocui.NewKey(gocui.MouseWheelUp, "", gocui.ModNone), "<mouse wheel up>"},
{"mouse wheel down", gocui.NewKey(gocui.MouseWheelDown, "", gocui.ModNone), "<mouse wheel down>"},
// Modifiers on special keys
{"shift+f1", gocui.NewKey(gocui.KeyF1, "", gocui.ModShift), "<s-f1>"},
{"alt+up", gocui.NewKey(gocui.KeyArrowUp, "", gocui.ModAlt), "<a-up>"},
{"meta+enter", gocui.NewKey(gocui.KeyEnter, "", gocui.ModMeta), "<m-enter>"},
{"ctrl+shift+up", gocui.NewKey(gocui.KeyArrowUp, "", gocui.ModCtrl|gocui.ModShift), "<c-s-up>"},
}
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)
})
}
}

View file

@ -6,63 +6,66 @@ package gocui
// Editor interface must be satisfied by gocui editors. // Editor interface must be satisfied by gocui editors.
type Editor interface { 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 // 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) // Editors. If f is a function with the appropriate signature, EditorFunc(f)
// is an Editor object that calls 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) // Edit calls f(v, key, mod)
func (f EditorFunc) Edit(v *View, key Key, mod Modifier) bool { func (f EditorFunc) Edit(v *View, key Key) bool {
return f(v, key, mod) return f(v, key)
} }
// DefaultEditor is the default editor. // DefaultEditor is the default editor.
var DefaultEditor Editor = EditorFunc(SimpleEditor) var DefaultEditor Editor = EditorFunc(SimpleEditor)
// SimpleEditor is used as the default gocui editor. // 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 { switch {
case (key.KeyName() == KeyBackspace || key.KeyName() == KeyBackspace2) && (mod&ModAlt) != 0, case key.Equals(NewKey(KeyBackspace, "", ModAlt)),
key.KeyName() == KeyCtrlW: key.Equals(NewKeyStrMod("w", ModCtrl)):
v.TextArea.BackSpaceWord() v.TextArea.BackSpaceWord()
case key.KeyName() == KeyBackspace || key.KeyName() == KeyBackspace2 || key.KeyName() == KeyCtrlH: case key.Equals(NewKeyName(KeyBackspace)):
v.TextArea.BackSpaceChar() v.TextArea.BackSpaceChar()
case key.KeyName() == KeyCtrlD || key.KeyName() == KeyDelete: case key.Equals(NewKeyStrMod("d", ModCtrl)),
key.Equals(NewKeyName(KeyDelete)):
v.TextArea.DeleteChar() v.TextArea.DeleteChar()
case key.KeyName() == KeyArrowDown: case key.Equals(NewKeyName(KeyArrowDown)):
v.TextArea.MoveCursorDown() v.TextArea.MoveCursorDown()
case key.KeyName() == KeyArrowUp: case key.Equals(NewKeyName(KeyArrowUp)):
v.TextArea.MoveCursorUp() 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() v.TextArea.MoveLeftWord()
case key.KeyName() == KeyArrowLeft || key.KeyName() == KeyCtrlB: case key.Equals(NewKeyName(KeyArrowLeft)),
key.Equals(NewKeyStrMod("b", ModCtrl)):
v.TextArea.MoveCursorLeft() 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() v.TextArea.MoveRightWord()
case key.KeyName() == KeyArrowRight || key.KeyName() == KeyCtrlF: case key.Equals(NewKeyName(KeyArrowRight)),
key.Equals(NewKeyStrMod("b", ModCtrl)):
v.TextArea.MoveCursorRight() v.TextArea.MoveCursorRight()
case key.KeyName() == KeyEnter: case key.Equals(NewKeyName(KeyEnter)):
v.TextArea.TypeCharacter("\n") v.TextArea.TypeCharacter("\n")
case key.KeyName() == KeySpace: case key.Equals(NewKeyName(KeyInsert)):
v.TextArea.TypeCharacter(" ")
case key.KeyName() == KeyInsert:
v.TextArea.ToggleOverwrite() v.TextArea.ToggleOverwrite()
case key.KeyName() == KeyCtrlU: case key.Equals(NewKeyStrMod("u", ModCtrl)):
v.TextArea.DeleteToStartOfLine() v.TextArea.DeleteToStartOfLine()
case key.KeyName() == KeyCtrlK: case key.Equals(NewKeyStrMod("k", ModCtrl)):
v.TextArea.DeleteToEndOfLine() v.TextArea.DeleteToEndOfLine()
case key.KeyName() == KeyCtrlA || key.KeyName() == KeyHome: case key.Equals(NewKeyStrMod("a", ModCtrl)),
key.Equals(NewKeyName(KeyHome)):
v.TextArea.GoToStartOfLine() v.TextArea.GoToStartOfLine()
case key.KeyName() == KeyCtrlE || key.KeyName() == KeyEnd: case key.Equals(NewKeyStrMod("e", ModCtrl)),
key.Equals(NewKeyName(KeyEnd)):
v.TextArea.GoToEndOfLine() v.TextArea.GoToEndOfLine()
case key.KeyName() == KeyCtrlW: case key.Equals(NewKeyStrMod("y", ModCtrl)):
v.TextArea.BackSpaceWord()
case key.KeyName() == KeyCtrlY:
v.TextArea.Yank() v.TextArea.Yank()
case key.Str() != "": case key.Str() != "" && key.Mod() == 0:
v.TextArea.TypeCharacter(key.Str()) v.TextArea.TypeCharacter(key.Str())
default: default:
return false return false

View file

@ -1264,17 +1264,16 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
switch ev.Type { switch ev.Type {
case eventKey: 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 // 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 // https://github.com/ghostty-org/ghostty/commit/010338354a0) it does
// this conversion only for non-bracketed paste mode, but I'm seeing it // this conversion only for non-bracketed paste mode, but I'm seeing it
// in bracketed paste mode. Whatever I'm missing here, converting '\r' // in bracketed paste mode. Whatever I'm missing here, converting '\r'
// back to '\n' fixes pasting multi-line text from Ghostty, and doesn't // back to '\n' fixes pasting multi-line text from Ghostty, and doesn't
// seem harmful for other terminal emulators. // seem harmful for other terminal emulators.
// if g.IsPasting && ev.Key.Equals(NewKeyStrMod("j", ModCtrl)) {
// KeyCtrlJ (int value 10) is '\r'.
if g.IsPasting && ev.Key.KeyName() == KeyCtrlJ {
ev.Key = NewKeyName(KeyEnter) 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 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 != "" { if link := v.viewLines[newY].line[newX].hyperlink; link != "" {
return g.openHyperlink(link, v.name) 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 { isMatch := func(binding *ViewMouseBinding) bool {
return binding.ViewName == view.Name() && return binding.ViewName == view.Name() &&
ev.Key.KeyName() == binding.Key && ev.Key.KeyName() == binding.Key &&
ev.Mod == binding.Modifier ev.Key.Mod() == binding.Modifier
} }
// first pass looks for ones that match the focused view // 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 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) { if ev.Key.Equals(g.NextSearchMatchKey) {
return v.gotoNextMatch() return v.gotoNextMatch()
} else if ev.Key.Equals(g.PrevSearchMatchKey) { } else if ev.Key.Equals(g.PrevSearchMatchKey) {
@ -1508,7 +1507,7 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) error {
if kb.handler == nil { if kb.handler == nil {
continue continue
} }
if !kb.matchKeypress(ev.Key, ev.Mod) { if !kb.matchKeypress(ev.Key) {
continue continue
} }
if g.matchView(v, kb) { 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) { if v != nil && g.matchView(v.ParentView, kb) {
matchingParentViewKb = 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 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 { 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 { if matched {
return nil return nil
} }
@ -1595,7 +1594,7 @@ func (g *Gui) matchView(v *View, kb *keybinding) bool {
if v == nil { if v == nil {
return false return false
} }
if v.Editable && kb.key.Str() != "" { if v.Editable && kb.key.Str() != "" && kb.key.Mod() == 0 {
return false return false
} }
if kb.viewName != v.name { if kb.viewName != v.name {

View file

@ -9,12 +9,15 @@ import "github.com/gdamore/tcell/v3"
type Key struct { type Key struct {
keyName KeyName keyName KeyName
str string str string
mod Modifier
} }
func NewKey(keyName KeyName, str string) Key { func NewKey(keyName KeyName, str string, mod Modifier) Key {
return Key{ return Key{
keyName: keyName, keyName: keyName,
str: str, str: str,
mod: mod,
} }
} }
@ -22,6 +25,7 @@ func NewKeyName(keyName KeyName) Key {
return Key{ return Key{
keyName: keyName, keyName: keyName,
str: "", str: "",
mod: ModNone,
} }
} }
@ -29,6 +33,15 @@ func NewKeyRune(ch rune) Key {
return Key{ return Key{
keyName: KeyName(tcell.KeyRune), keyName: KeyName(tcell.KeyRune),
str: string(ch), 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 return k.str
} }
func (k Key) Mod() Modifier {
return k.mod
}
func (k Key) IsSet() bool { func (k Key) IsSet() bool {
return k.keyName != 0 return k.keyName != 0
} }
func (k Key) Equals(otherKey Key) bool { 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
} }

View file

@ -35,8 +35,8 @@ func newKeybinding(viewname string, key Key, mod Modifier, handler func(*Gui, *V
} }
// matchKeypress returns if the keybinding matches the keypress. // matchKeypress returns if the keybinding matches the keypress.
func (kb *keybinding) matchKeypress(key Key, mod Modifier) bool { func (kb *keybinding) matchKeypress(key Key) bool {
return kb.key.Equals(key) && kb.mod == mod return kb.key.Equals(key)
} }
// Special keys. // Special keys.
@ -70,40 +70,11 @@ const (
// Keys combinations. // Keys combinations.
const ( const (
KeyCtrlTilde = KeyName(tcell.KeyF64) // arbitrary assignment 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) KeyBackspace = KeyName(tcell.KeyBackspace)
KeyCtrlH = KeyName(tcell.KeyCtrlH)
KeyTab = KeyName(tcell.KeyTab) KeyTab = KeyName(tcell.KeyTab)
KeyBacktab = KeyName(tcell.KeyBacktab) KeyBacktab = KeyName(tcell.KeyBacktab)
KeyCtrlI = KeyName(tcell.KeyCtrlI)
KeyCtrlJ = KeyName(tcell.KeyCtrlJ)
KeyCtrlK = KeyName(tcell.KeyCtrlK)
KeyCtrlL = KeyName(tcell.KeyCtrlL)
KeyEnter = KeyName(tcell.KeyEnter) 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) KeyEsc = KeyName(tcell.KeyEscape)
KeySpace = KeyName(32)
KeyBackspace2 = KeyName(tcell.KeyBackspace2)
KeyCtrl8 = KeyName(tcell.KeyBackspace2) // same key as in termbox-go
// The following assignments were used in termbox implementation. // The following assignments were used in termbox implementation.
// In tcell, these are not keys per se. But in gocui we have them // In tcell, these are not keys per se. But in gocui we have them
@ -123,8 +94,9 @@ const (
// Modifiers. // Modifiers.
const ( const (
ModNone Modifier = Modifier(0) ModNone Modifier = Modifier(0)
ModShift = Modifier(tcell.ModShift)
ModCtrl = Modifier(tcell.ModCtrl)
ModAlt = Modifier(tcell.ModAlt) ModAlt = Modifier(tcell.ModAlt)
ModMotion = Modifier(2) // just picking an arbitrary number here that doesn't clash with tcell.ModAlt ModMeta = Modifier(tcell.ModMeta)
// ModCtrl doesn't work with keyboard keys. Use CtrlKey in Key and ModNone. This is was for mouse clicks only (tcell.v1) ModMotion = Modifier(16) // just picking an arbitrary number here that doesn't clash with tcell's modifiers
// ModCtrl = Modifier(tcell.ModCtrl)
) )

View file

@ -163,7 +163,6 @@ type gocuiEventType uint8
// The 'Err' field is valid if 'Type' is 'eventError'. // The 'Err' field is valid if 'Type' is 'eventError'.
type GocuiEvent struct { type GocuiEvent struct {
Type gocuiEventType Type gocuiEventType
Mod Modifier
Key Key Key Key
Width int Width int
Height int Height int
@ -294,42 +293,15 @@ func (g *Gui) pollEvent() GocuiEvent {
ch := "" ch := ""
if k == tcell.KeyRune { if k == tcell.KeyRune {
ch = tev.Str() ch = tev.Str()
if ch == " " { } else if k >= tcell.KeyCtrlA && k <= tcell.KeyCtrlZ {
// special handling for spacebar ch = string(rune('a' + (k - tcell.KeyCtrlA)))
k = tcell.Key(KeySpace) k = tcell.KeyRune
ch = ""
}
} }
mod := tev.Modifiers() 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{ return GocuiEvent{
Type: eventKey, Type: eventKey,
Key: NewKey(KeyName(k), ch), Key: NewKey(KeyName(k), ch, Modifier(mod)),
Mod: Modifier(mod),
} }
case *tcell.EventMouse: case *tcell.EventMouse:
x, y := tev.Position() x, y := tev.Position()
@ -411,8 +383,7 @@ func (g *Gui) pollEvent() GocuiEvent {
Type: eventMouse, Type: eventMouse,
MouseX: x, MouseX: x,
MouseY: y, MouseY: y,
Key: NewKeyName(mouseKey), Key: NewKey(mouseKey, "", mouseMod),
Mod: mouseMod,
} }
case *tcell.EventFocus: case *tcell.EventFocus:
return GocuiEvent{ return GocuiEvent{

View file

@ -119,7 +119,7 @@ func (self *CommitDescriptionController) handleTogglePanel() error {
// which is common in pasted code snippets. // which is common in pasted code snippets.
view := self.Context().GetView() view := self.Context().GetView()
for range 4 { for range 4 {
view.Editor.Edit(view, gocui.NewKeyRune(' '), 0) view.Editor.Edit(view, gocui.NewKeyRune(' '))
} }
return nil return nil
} }

View file

@ -130,7 +130,7 @@ func (self *CommitMessageController) handleTogglePanel() error {
// switch to the description panel. // switch to the description panel.
view := self.context().GetView() view := self.context().GetView()
for range 4 { for range 4 {
view.Editor.Edit(view, gocui.NewKeyRune(' '), 0) view.Editor.Edit(view, gocui.NewKeyRune(' '))
} }
return nil return nil
} }

View file

@ -4,33 +4,33 @@ import (
"github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gocui"
) )
func (gui *Gui) handleEditorKeypress(v *gocui.View, key gocui.Key, mod gocui.Modifier, allowMultiline bool) bool { func (gui *Gui) handleEditorKeypress(v *gocui.View, key gocui.Key, allowMultiline bool) bool {
if key.KeyName() == gocui.KeyEnter && allowMultiline { if key.Equals(gocui.NewKeyName(gocui.KeyEnter)) && allowMultiline {
v.TextArea.TypeCharacter("\n") v.TextArea.TypeCharacter("\n")
v.RenderTextArea() v.RenderTextArea()
return true 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- // 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 // render the commit message length on each keypress
func (gui *Gui) commitMessageEditor(v *gocui.View, key gocui.Key, mod gocui.Modifier) bool { func (gui *Gui) commitMessageEditor(v *gocui.View, key gocui.Key) bool {
matched := gui.handleEditorKeypress(v, key, mod, false) matched := gui.handleEditorKeypress(v, key, false)
v.RenderTextArea() v.RenderTextArea()
gui.c.Contexts().CommitMessage.RenderSubtitle() gui.c.Contexts().CommitMessage.RenderSubtitle()
return matched return matched
} }
func (gui *Gui) commitDescriptionEditor(v *gocui.View, key gocui.Key, mod gocui.Modifier) bool { func (gui *Gui) commitDescriptionEditor(v *gocui.View, key gocui.Key) bool {
matched := gui.handleEditorKeypress(v, key, mod, true) matched := gui.handleEditorKeypress(v, key, true)
v.RenderTextArea() v.RenderTextArea()
return matched return matched
} }
func (gui *Gui) promptEditor(v *gocui.View, key gocui.Key, mod gocui.Modifier) bool { func (gui *Gui) promptEditor(v *gocui.View, key gocui.Key) bool {
matched := gui.handleEditorKeypress(v, key, mod, false) matched := gui.handleEditorKeypress(v, key, false)
v.RenderTextArea() v.RenderTextArea()
@ -46,8 +46,8 @@ func (gui *Gui) promptEditor(v *gocui.View, key gocui.Key, mod gocui.Modifier) b
return matched return matched
} }
func (gui *Gui) searchEditor(v *gocui.View, key gocui.Key, mod gocui.Modifier) bool { func (gui *Gui) searchEditor(v *gocui.View, key gocui.Key) bool {
matched := gui.handleEditorKeypress(v, key, mod, false) matched := gui.handleEditorKeypress(v, key, false)
v.RenderTextArea() v.RenderTextArea()
searchString := v.TextArea.GetContent() searchString := v.TextArea.GetContent()

View file

@ -34,7 +34,7 @@ func (self *GuiDriver) PressKey(keyStr string) {
} }
self.gui.g.ReplayedEvents.Keys <- gocui.NewTcellKeyEventWrapper( 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, 0,
) )

View file

@ -72,7 +72,7 @@ func RunTUI(raceDetector bool) {
log.Panicln(err) 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) 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 { 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, mod gocui.Modifier) bool { return func(v *gocui.View, key gocui.Key) bool {
matched := f(v, key, mod) matched := f(v, key)
if matched { if matched {
self.filterWithString(v.TextArea.GetContent()) self.filterWithString(v.TextArea.GetContent())
} }