Keybinding revamp (#5563)

Revamp lazygit's keybinding mechanism to support richer keybindings for
those terminals that support the newer keyboard protocols. This makes it
possible to use keybindings such as `ctrl-alt-shift-x`.

Change the default keybindings for moving commits up and down 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, change
the default binding for submitting a commit from the commit description
editor 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.

See docs/keybindings/Custom_Keybindings.md for caveats about terminal
compatibility.
This commit is contained in:
Stefan Haller 2026-04-30 22:30:27 +02:00 committed by GitHub
commit 396e1ae8b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1171 additions and 398 deletions

View file

@ -637,7 +637,9 @@ keybinding:
confirm: <enter>
confirmMenu: <enter>
confirmSuggestion: <enter>
confirmInEditor: <a-enter>
# <m-enter> on Mac
confirmInEditor: <c-enter>
confirmInEditor-alt: <c-s>
remove: d
new: "n"
@ -735,8 +737,8 @@ keybinding:
setFixupMessage: c
createFixupCommit: F
squashAboveCommits: S
moveDownCommit: <c-j>
moveUpCommit: <c-k>
moveDownCommit: <a-down>
moveUpCommit: <a-up>
amendToCommit: A
resetCommitAuthor: a
pickCommit: p
@ -746,7 +748,7 @@ keybinding:
markCommitAsBaseForRebase: B
tagCommit: T
checkoutCommit: <space>
resetCherryPick: <c-R>
resetCherryPick: <c-r>
copyCommitAttributeToClipboard: "y"
openLogMenu: <c-l>
openInBrowser: o

View file

@ -1,63 +1,96 @@
## Possible keybindings
| Put in | You will get |
|---------------|----------------|
| `<f1>` | F1 |
| `<f2>` | F2 |
| `<f3>` | F3 |
| `<f4>` | F4 |
| `<f5>` | F5 |
| `<f6>` | F6 |
| `<f7>` | F7 |
| `<f8>` | F8 |
| `<f9>` | F9 |
| `<f10>` | F10 |
| `<f11>` | F11 |
| `<f12>` | F12 |
| `<insert>` | Insert |
| `<delete>` | Delete |
| `<home>` | Home |
| `<end>` | End |
| `<pgup>` | Pgup |
| `<pgdown>` | Pgdn |
| `<up>` | ArrowUp |
| `<s-up>` | ShiftArrowUp |
| `<down>` | ArrowDown |
| `<s-down>` | ShiftArrowDown |
| `<left>` | ArrowLeft |
| `<right>` | ArrowRight |
| `<tab>` | Tab |
| `<backtab>` | Backtab |
| `<enter>` | Enter |
| `<a-enter>` | AltEnter |
| `<esc>` | Esc |
| `<backspace>` | Backspace |
| `<c-space>` | CtrlSpace |
| `<c-/>` | CtrlSlash |
| `<space>` | Space |
| `<c-a>` | CtrlA |
| `<c-b>` | CtrlB |
| `<c-c>` | CtrlC |
| `<c-d>` | CtrlD |
| `<c-e>` | CtrlE |
| `<c-f>` | CtrlF |
| `<c-g>` | CtrlG |
| `<c-j>` | CtrlJ |
| `<c-k>` | CtrlK |
| `<c-l>` | CtrlL |
| `<c-n>` | CtrlN |
| `<c-o>` | CtrlO |
| `<c-p>` | CtrlP |
| `<c-q>` | CtrlQ |
| `<c-r>` | CtrlR |
| `<c-s>` | CtrlS |
| `<c-t>` | CtrlT |
| `<c-u>` | CtrlU |
| `<c-v>` | CtrlV |
| `<c-w>` | CtrlW |
| `<c-x>` | CtrlX |
| `<c-y>` | CtrlY |
| `<c-z>` | CtrlZ |
| `<c-4>` | Ctrl4 |
| `<c-5>` | Ctrl5 |
| `<c-6>` | Ctrl6 |
| `<c-8>` | Ctrl8 |
## Custom Keybindings
A keybinding is one of:
- A single printable character, e.g. `q`, `?`, `5`. Uppercase letters mean
shift+letter — write `A`, not `<s-a>`.
- A special key name in angle brackets, e.g. `<enter>`, `<f1>`, `<up>`.
- A key with modifiers in angle brackets, e.g. `<c-c>` (Ctrl+C), `<c-s-up>`
(Ctrl+Shift+Up).
- The literal string `<disabled>` 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:
- `<c-s-up>`
- `<ctrl-shift-up>`
- `<ctrl+shift+up>`
- `<s-c-up>`
### Special key names
| Put in | You will get |
| --------------------------------------- | ------------------- |
| `<f1>` `<f12>` | F1 F12 |
| `<insert>` | Insert |
| `<delete>` | Delete |
| `<home>` | Home |
| `<end>` | End |
| `<pgup>` | PageUp |
| `<pgdown>` | PageDown |
| `<up>` | ArrowUp |
| `<down>` | ArrowDown |
| `<left>` | ArrowLeft |
| `<right>` | ArrowRight |
| `<tab>` | Tab |
| `<backtab>` | Shift+Tab |
| `<enter>` | Enter |
| `<esc>` | Escape |
| `<backspace>` | Backspace |
| `<space>` | Space |
| `<mouse wheel up>`/`<mouse wheel down>` | Mouse wheel up/down |
These can be combined with modifiers, e.g. `<c-up>`, `<c-s-f1>`, `<a-enter>`.
### Special characters with modifiers
`<minus>` and `<plus>` are keyword forms for `-` and `+` when combined with a
modifier (e.g. `<c-minus>` for Ctrl+`-`). Without modifiers, write `-` and `+`
directly. `<space>` is the keyword for the space character.
### Combinations that are rejected
These look reasonable but can't actually be delivered by a terminal:
- `<s-a>` (shift alone on a rune) — terminals fold shift into the rune itself,
so shift+a arrives as `A`. Write `A` instead.
- `<c-A>`, `<a-A>`, etc. (modifier on an uppercase ASCII letter) — write
`<c-s-a>` 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'
`

View file

@ -98,8 +98,8 @@ _Legend: `<c-b>` means ctrl+b, `<a-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). |
| `` <c-j> `` | Move commit down one | |
| `` <c-k> `` | Move commit up one | |
| `` <a-down> `` | Move commit down one | |
| `` <a-up> `` | 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. |
@ -221,8 +221,8 @@ _Legend: `<c-b>` means ctrl+b, `<a-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 | |
| `` <mouse wheel down> (fn+up) `` | Scroll down | |
| `` <mouse wheel up> (fn+down) `` | Scroll up | |
| `` <tab> `` | Switch view | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Search the current view by text | |

View file

@ -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。 |
| `` <c-j> `` | コミットを1つ下に移動 | |
| `` <c-k> `` | コミットを1つ上に移動 | |
| `` <a-down> `` | コミットを1つ下に移動 | |
| `` <a-up> `` | コミットを1つ上に移動 | |
| `` V `` | ペースト(チェリーピック) | |
| `` B `` | リベース用のベースコミットとしてマーク | 次のリベース用のベースコミットを選択します。ブランチにリベースするとき、ベースコミットより上のコミットのみが持ち込まれます。これは `git rebase --onto` コマンドを使用します。 |
| `` A `` | 修正 | ステージされた変更でコミットを修正します。選択したコミットがHEADコミットの場合、これは `git commit --amend` を実行します。それ以外の場合、コミットはリベースを通じて修正されます。 |
@ -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) `` | 上にスクロール | |
| `` <mouse wheel down> (fn+up) `` | 下にスクロール | |
| `` <mouse wheel up> (fn+down) `` | 上にスクロール | |
| `` <tab> `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 |
| `` <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 |
|-----|--------|-------------|
| `` mouse wheel down (fn+up) `` | 아래로 스크롤 | |
| `` mouse wheel up (fn+down) `` | 위로 스크롤 | |
| `` <mouse wheel down> (fn+up) `` | 아래로 스크롤 | |
| `` <mouse wheel up> (fn+down) `` | 위로 스크롤 | |
| `` <tab> `` | 패널 전환 | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | 검색 시작 | |
@ -310,8 +310,8 @@ _Legend: `<c-b>` means ctrl+b, `<a-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) |
| `` <c-j> `` | 커밋을 1개 아래로 이동 | |
| `` <c-k> `` | 커밋을 1개 위로 이동 | |
| `` <a-down> `` | 커밋을 1개 아래로 이동 | |
| `` <a-up> `` | 커밋을 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 |

View file

@ -170,8 +170,8 @@ _Legend: `<c-b>` means ctrl+b, `<a-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 |
| `` <c-j> `` | Verplaats commit 1 naar beneden | |
| `` <c-k> `` | Verplaats commit 1 naar boven | |
| `` <a-down> `` | Verplaats commit 1 naar beneden | |
| `` <a-up> `` | 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 |
@ -229,8 +229,8 @@ _Legend: `<c-b>` means ctrl+b, `<a-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 | |
| `` <mouse wheel down> (fn+up) `` | Scroll omlaag | |
| `` <mouse wheel up> (fn+down) `` | Scroll omhoog | |
| `` <tab> `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Start met zoeken | |

View file

@ -71,8 +71,8 @@ _Legenda: `<c-b>` oznacza ctrl+b, `<a-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). |
| `` <c-j> `` | Przesuń commit w dół | |
| `` <c-k> `` | Przesuń commit w górę | |
| `` <a-down> `` | Przesuń commit w dół | |
| `` <a-up> `` | 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. |
@ -179,8 +179,8 @@ _Legenda: `<c-b>` oznacza ctrl+b, `<a-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ę | |
| `` <mouse wheel down> (fn+up) `` | Przewiń w dół | |
| `` <mouse wheel up> (fn+down) `` | Przewiń w górę | |
| `` <tab> `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Szukaj w bieżącym widoku po tekście | |

View file

@ -174,8 +174,8 @@ _Legend: `<c-b>` means ctrl+b, `<a-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). |
| `` <c-j> `` | Mover commit um para baixo | |
| `` <c-k> `` | Mover o commit um para cima | |
| `` <a-down> `` | Mover commit um para baixo | |
| `` <a-up> `` | 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. |
@ -233,8 +233,8 @@ _Legend: `<c-b>` means ctrl+b, `<a-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 | |
| `` <mouse wheel down> (fn+up) `` | Rolar para baixo | |
| `` <mouse wheel up> (fn+down) `` | Rolar para cima | |
| `` <tab> `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Pesquisar na visualização atual por texto | |

View file

@ -104,8 +104,8 @@ _Связки клавиш_
| Key | Action | Info |
|-----|--------|-------------|
| `` mouse wheel down (fn+up) `` | Прокрутить вниз | |
| `` mouse wheel up (fn+down) `` | Прокрутить вверх | |
| `` <mouse wheel down> (fn+up) `` | Прокрутить вниз | |
| `` <mouse wheel up> (fn+down) `` | Прокрутить вверх | |
| `` <tab> `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | Найти | |
@ -180,8 +180,8 @@ _Связки клавиш_
| `` p `` | Pick | Выбрать коммит (в середине перебазирования) |
| `` F `` | Создать fixup коммит | Создать fixup коммит для этого коммита |
| `` S `` | Apply fixup commits | Объединить все 'fixup!' коммиты выше в выбранный коммит (автосохранение) |
| `` <c-j> `` | Переместить коммит вниз на один | |
| `` <c-k> `` | Переместить коммит вверх на один | |
| `` <a-down> `` | Переместить коммит вниз на один | |
| `` <a-up> `` | Переместить коммит вверх на один | |
| `` 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 | Править последний коммит с проиндексированными изменениями |

View file

@ -135,8 +135,8 @@ _图例`<c-b>` 意味着ctrl+b, `<a-b>意味着Alt+b, `B` 意味着shift+b_
| `` p `` | 拣选(Pick) | 标记选中的提交为 picked变基过程中。这意味该提交将在后续的变基中保留。 |
| `` F `` | 为此提交创建修正 | 创建修正提交 |
| `` S `` | 应用该修复提交 | 压缩所选提交之上或当前分支的所有 “fixup!” 提交(自动压缩)。 |
| `` <c-j> `` | 下移提交 | |
| `` <c-k> `` | 上移提交 | |
| `` <a-down> `` | 下移提交 | |
| `` <a-up> `` | 上移提交 | |
| `` V `` | 粘贴提交(拣选) | |
| `` B `` | 标记一个主提交用于变基 | 选择下一次变基的主提交。当您变基到一个分支时只有高于主提交的提交才会被引入。这使用“git rebase --onto”命令。 |
| `` A `` | 修补(Amend) | 用已暂存的变更来修补提交 |
@ -332,8 +332,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) `` | 向上滚动 | |
| `` <mouse wheel down> (fn+up) `` | 向下滚动 | |
| `` <mouse wheel up> (fn+down) `` | 向上滚动 | |
| `` <tab> `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) |
| `` <esc> `` | 退出回到侧边面板 | |
| `` / `` | 开始搜索 | |

View file

@ -80,8 +80,8 @@ _說明`<c-b>` 表示 CtrlB、`<a-b>` 表示 AltB`B`表示 ShiftB
| Key | Action | Info |
|-----|--------|-------------|
| `` mouse wheel down (fn+up) `` | 向下捲動 | |
| `` mouse wheel up (fn+down) `` | 向上捲動 | |
| `` <mouse wheel down> (fn+up) `` | 向下捲動 | |
| `` <mouse wheel up> (fn+down) `` | 向上捲動 | |
| `` <tab> `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). |
| `` <esc> `` | Exit back to side panel | |
| `` / `` | 搜尋 | |
@ -194,8 +194,8 @@ _說明`<c-b>` 表示 CtrlB、`<a-b>` 表示 AltB`B`表示 ShiftB
| `` p `` | 挑選 | 挑選提交 (於變基過程中) |
| `` F `` | 建立修復提交 | 為此提交建立修復提交 |
| `` S `` | 壓縮上方所有「fixup」提交自動壓縮 | 是否壓縮上方 {{.commit}} 所有「fixup」提交 |
| `` <c-j> `` | 向下移動提交 | |
| `` <c-k> `` | 向上移動提交 | |
| `` <a-down> `` | 向下移動提交 | |
| `` <a-up> `` | 向上移動提交 | |
| `` V `` | 貼上提交 (揀選) | |
| `` B `` | 為了變基已標注提交為基準提交 | 請為了下一次變基選擇一項基準提交;此將執行 `git rebase --onto`。 |
| `` A `` | 修改 | 使用已預存的更改修正提交 |

View file

@ -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())
}

View file

@ -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)

View file

@ -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) {

View file

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

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

@ -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"` // <m-enter> 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: "<enter>",
ConfirmMenu: "<enter>",
ConfirmSuggestion: "<enter>",
ConfirmInEditor: "<a-enter>",
ConfirmInEditor: platformKeyBinding(platform, map[string]string{"darwin": "<m-enter>"}, "<c-enter>"),
ConfirmInEditorAlt: "<c-s>",
Remove: "d",
New: "n",
@ -1033,8 +1039,8 @@ func GetDefaultConfig() *UserConfig {
SetFixupMessage: "c",
CreateFixupCommit: "F",
SquashAboveCommits: "S",
MoveDownCommit: "<c-j>",
MoveUpCommit: "<c-k>",
MoveDownCommit: "<a-down>",
MoveUpCommit: "<a-up>",
AmendToCommit: "A",
ResetCommitAuthor: "a",
PickCommit: "p",
@ -1044,7 +1050,7 @@ func GetDefaultConfig() *UserConfig {
MarkCommitAsBaseForRebase: "B",
CreateTag: "T",
CheckoutCommit: "<space>",
ResetCherryPick: "<c-R>",
ResetCherryPick: "<c-r>",
CopyCommitAttributeToClipboard: "y",
OpenLogMenu: "<c-l>",
OpenInBrowser: "o",
@ -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
}

View file

@ -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

View file

@ -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 {

View file

@ -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
}

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.
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
)

View file

@ -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{

View file

@ -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 := ""

View file

@ -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
}

View file

@ -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
}

View file

@ -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()

View file

@ -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

View file

@ -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,10 +28,13 @@ 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),
tcell.NewEventKey(tcell.Key(key.KeyName()), key.Str(), tcell.ModMask(key.Mod())),
0,
)

View file

@ -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,
}
}

View file

@ -1,50 +0,0 @@
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"
)
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(key string) gocui.Key {
if key == "<disabled>" {
return gocui.Key{}
}
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{}
}

View file

@ -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 {

View file

@ -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,
}

View file

@ -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),
})
}

View file

@ -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),
}
})

View file

@ -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(),

View file

@ -2263,6 +2263,15 @@ keybinding:
redo: <c-z>
- 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: <c-j>
moveUpCommit: <c-k>
universal:
confirmInEditor: <a-enter>
`,
},
}

View file

@ -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())
}

View file

@ -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",
@ -1040,7 +1040,7 @@
},
"resetCherryPick": {
"type": "string",
"default": "\u003cc-R\u003e"
"default": "\u003cc-r\u003e"
},
"copyCommitAttributeToClipboard": {
"type": "string",
@ -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",