From 948f3e1a79ed6b1489ec1b017f442483b5fafa75 Mon Sep 17 00:00:00 2001 From: Pablu Date: Tue, 28 Jul 2026 14:55:38 +0200 Subject: [PATCH] make keybinds configurable and consolidate --- README.md | 95 +++++- branch_completion.go | 10 +- branch_completion_test.go | 2 +- config.go | 9 +- config_test.go | 58 +++- keybindings.go | 676 ++++++++++++++++++++++++++++++++++++++ main.go | 1 + pr_editor.go | 31 +- text_editor.go | 148 +++++---- text_editor_test.go | 15 + tui.go | 358 +++++++++++++++----- tui_test.go | 224 ++++++++++++- 12 files changed, 1469 insertions(+), 158 deletions(-) create mode 100644 keybindings.go diff --git a/README.md b/README.md index 1ca6754..855c5bd 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,99 @@ directory = "" # defaults to the OS user cache directory [editing] mode = "vim" # "vim" or "standard"; description field only for now + +[keybindings.general] +quit = ["q", "ctrl+c"] +help = ["?", "f1"] +refresh = ["r"] +back = ["b", "esc"] +confirm = ["y"] +reject = ["n", "esc"] + +[keybindings.navigation] +# Shared by the picker, dashboard, thread panes, help, and Vim Normal/Visual modes. +down = ["j", "down"] +up = ["k", "up"] +left = ["h", "left"] +right = ["l", "right"] +first = ["g"] +last = ["G"] +page_down = ["ctrl+d", "pgdown"] +page_up = ["ctrl+u", "pgup"] + +[keybindings.views] +open = ["enter", "l"] +dashboard = ["d"] +edit = ["e"] +toggle_list = ["tab"] + +[keybindings.threads] +search = ["/"] +clear_filter = ["F"] +next_unread = ["n"] +previous_unread = ["N"] +reply = ["c"] +resolve = ["R"] +toggle = ["enter"] +fold_prefix = ["z"] +fold_toggle = ["a"] + +[keybindings.input] +cancel = ["esc"] +submit = ["ctrl+s"] +newline = ["enter"] +delete_backward = ["backspace"] +delete_forward = ["delete"] +clear = ["ctrl+u"] +next_field = ["tab"] +previous_field = ["shift+tab"] +next_completion = ["ctrl+n"] +previous_completion = ["ctrl+p"] +line_start = ["home", "ctrl+a"] +line_end = ["end", "ctrl+e"] + +[keybindings.vim] +insert = ["i"] +append = ["a"] +insert_line_start = ["I"] +append_line_end = ["A"] +open_below = ["o"] +open_above = ["O"] +replace_character = ["s"] +visual = ["v"] +visual_line = ["V"] +selection_other_end = ["o"] +yank = ["y"] +delete = ["d", "x", "delete"] +delete_before = ["X", "backspace"] +paste = ["p"] +line_start = ["0", "home"] +first_non_blank = ["^"] +line_end = ["$", "end"] +word_forward = ["w"] +big_word_forward = ["W"] +word_backward = ["b"] +big_word_backward = ["B"] +word_end = ["e"] +big_word_end = ["E"] +go_prefix = ["g"] +find_forward = ["f"] +find_backward = ["F"] +till_forward = ["t"] +till_backward = ["T"] +repeat_find = [";"] +repeat_find_reverse = [","] ``` +Every command binding accepts one or more Bubble Tea key names. Omitted +settings retain their defaults, while an explicitly configured action replaces +its default keys. Printable keys remain text in Insert mode, reply drafts, and +search queries; command bindings apply in the appropriate non-text context. +The contextual `?` popup and compact screen footers use the configured keys. +Configuration loading also checks each active context independently. A key may +be reused on unrelated screens, but assigning it to two different actions that +can be active together reports the context and both conflicting actions. + When cached data exists, the picker and PR details are rendered immediately from that snapshot while a live GitHub refresh runs in the background. Cached screens are labelled with their save time and are replaced automatically when @@ -142,7 +233,7 @@ a written summary retain a compact one-line body, while timestamps and commit SHAs are omitted. Set `compact_reviews = false` to restore the complete review history and metadata. -## Keys +## Default keys | Key | Action | | --- | --- | @@ -156,7 +247,7 @@ history and metadata. | `n` / `N` | Next / previous thread with a new update | | `c` | Compose a reply to the selected thread | | `R` | Resolve or unresolve the selected thread | -| `↑` / `↓` | Choose a fuzzy-search match | +| `ctrl-p` / `ctrl-n` | Choose the previous / next fuzzy-search or branch-completion match | | `g` / `G` | First / last item | | `enter` / `l` | Open the selected PR dashboard or its review threads | | `enter` | Toggle the selected review thread | diff --git a/branch_completion.go b/branch_completion.go index 6da21e5..541cb8a 100644 --- a/branch_completion.go +++ b/branch_completion.go @@ -177,7 +177,15 @@ func (m App) branchCompletionLines(width int) []string { if len(suggestions) == 0 { return []string{dimStyle.Render(" no matching repository branches")} } - lines := []string{dimStyle.Render(" ctrl-p/n choose • tab/enter complete • tab again advances")} + lines := []string{dimStyle.Render(fmt.Sprintf( + " %s choose • %s complete • %s again advances", + primaryCombinedKeyLabel( + m.keybindings.Input.PreviousCompletion, + m.keybindings.Input.NextCompletion, + ), + primaryCombinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.Newline), + primaryKeyLabel(m.keybindings.Input.NextField), + ))} now := time.Now() for index, suggestion := range suggestions { prefix := " " diff --git a/branch_completion_test.go b/branch_completion_test.go index 736b855..6a2c1aa 100644 --- a/branch_completion_test.go +++ b/branch_completion_test.go @@ -95,7 +95,7 @@ func TestTargetBranchSuggestionsRenderAndValidationRejectsUnknownBranch(t *testi m.prEditBranches = []RepositoryBranch{{Name: "main"}, {Name: "release/2.0"}} view := ansi.Strip(strings.Join(m.prEditFieldLines("target branch", prEditBaseField, 80), "\n")) - if !strings.Contains(view, "release/2.0") || !strings.Contains(view, "tab/enter complete") { + if !strings.Contains(view, "release/2.0") || !strings.Contains(view, "tab / enter complete") { t.Fatalf("branch suggestions missing:\n%s", view) } if err := m.validatePREdit(); err == nil || !strings.Contains(err.Error(), "not an available") { diff --git a/config.go b/config.go index a715faa..aabc9cb 100644 --- a/config.go +++ b/config.go @@ -36,6 +36,7 @@ type Config struct { Threads ThreadConfig `toml:"threads"` Cache CacheConfig `toml:"cache"` Editing EditingConfig `toml:"editing"` + KeyBindings KeyBindings `toml:"keybindings"` } type DisplayConfig struct { @@ -85,8 +86,9 @@ func defaultConfig() Config { StatusOrder: []string{"unresolved", "outdated", "resolved"}, WithinStatus: "file", }, - Cache: CacheConfig{Enabled: true, MaxAge: configDuration{7 * 24 * time.Hour}}, - Editing: EditingConfig{Mode: "vim"}, + Cache: CacheConfig{Enabled: true, MaxAge: configDuration{7 * 24 * time.Hour}}, + Editing: EditingConfig{Mode: "vim"}, + KeyBindings: defaultKeyBindings(), } } @@ -181,6 +183,9 @@ func validateConfig(config Config) error { default: return fmt.Errorf("editing.mode must be standard or vim") } + if err := validateKeyBindings(config.KeyBindings); err != nil { + return err + } return nil } diff --git a/config_test.go b/config_test.go index aeec10d..4121974 100644 --- a/config_test.go +++ b/config_test.go @@ -56,6 +56,10 @@ directory = "/tmp/gh-threads-cache" [editing] mode = "standard" + +[keybindings.navigation] +down = ["ctrl+j"] +up = ["ctrl+k"] ` if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatal(err) @@ -76,11 +80,63 @@ mode = "standard" strings.Join(got.Threads.StatusOrder, ",") != "resolved,unresolved,outdated" || got.Threads.WithinStatus != "timestamp" || got.Cache.Enabled || got.Cache.MaxAge.Duration != 48*time.Hour || got.Cache.Directory != "/tmp/gh-threads-cache" || - got.Editing.Mode != "standard" { + got.Editing.Mode != "standard" || + strings.Join(got.KeyBindings.Navigation.Down, ",") != "ctrl+j" || + strings.Join(got.KeyBindings.Navigation.Up, ",") != "ctrl+k" { t.Fatalf("config = %#v", got) } } +func TestValidateConfigRejectsEmptyKeyBinding(t *testing.T) { + config := defaultConfig() + config.KeyBindings.Navigation.Down = nil + err := validateConfig(config) + if err == nil || !strings.Contains(err.Error(), "keybindings.navigation.down") { + t.Fatalf("empty binding error = %v", err) + } +} + +func TestValidateConfigRejectsKeyConflictsInTheSameContext(t *testing.T) { + tests := []struct { + name string + change func(*Config) + context string + actions []string + }{ + { + name: "thread navigation and reply", + change: func(config *Config) { + config.KeyBindings.Threads.Reply = []string{"j"} + }, + context: "review threads", + actions: []string{"down", "reply"}, + }, + { + name: "vim motion and cancel", + change: func(config *Config) { + config.KeyBindings.Input.Cancel = []string{"b"} + }, + context: "Vim Normal mode", + actions: []string{"cancel", "word_backward"}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + config := defaultConfig() + test.change(&config) + err := validateConfig(config) + if err == nil || !strings.Contains(err.Error(), test.context) { + t.Fatalf("conflict error = %v", err) + } + for _, action := range test.actions { + if !strings.Contains(err.Error(), action) { + t.Fatalf("conflict error does not name %q: %v", action, err) + } + } + }) + } +} + func TestLoadConfigRejectsUnknownSettings(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") if err := os.WriteFile(path, []byte("refesh_interval = \"10s\"\n"), 0o600); err != nil { diff --git a/keybindings.go b/keybindings.go new file mode 100644 index 0000000..fb8d54f --- /dev/null +++ b/keybindings.go @@ -0,0 +1,676 @@ +package main + +import ( + "fmt" + "strings" + "unicode/utf8" +) + +type KeyBindings struct { + General GeneralKeyBindings `toml:"general"` + Navigation NavigationKeyBindings `toml:"navigation"` + Views ViewKeyBindings `toml:"views"` + Threads ThreadKeyBindings `toml:"threads"` + Input InputKeyBindings `toml:"input"` + Vim VimKeyBindings `toml:"vim"` +} + +type GeneralKeyBindings struct { + Quit []string `toml:"quit"` + Help []string `toml:"help"` + Refresh []string `toml:"refresh"` + Back []string `toml:"back"` + Confirm []string `toml:"confirm"` + Reject []string `toml:"reject"` +} + +type NavigationKeyBindings struct { + Down []string `toml:"down"` + Up []string `toml:"up"` + Left []string `toml:"left"` + Right []string `toml:"right"` + First []string `toml:"first"` + Last []string `toml:"last"` + PageDown []string `toml:"page_down"` + PageUp []string `toml:"page_up"` +} + +type ViewKeyBindings struct { + Open []string `toml:"open"` + Dashboard []string `toml:"dashboard"` + Edit []string `toml:"edit"` + ToggleList []string `toml:"toggle_list"` +} + +type ThreadKeyBindings struct { + Search []string `toml:"search"` + ClearFilter []string `toml:"clear_filter"` + NextUnread []string `toml:"next_unread"` + PreviousUnread []string `toml:"previous_unread"` + Reply []string `toml:"reply"` + Resolve []string `toml:"resolve"` + Toggle []string `toml:"toggle"` + FoldPrefix []string `toml:"fold_prefix"` + FoldToggle []string `toml:"fold_toggle"` +} + +type InputKeyBindings struct { + Cancel []string `toml:"cancel"` + Submit []string `toml:"submit"` + Newline []string `toml:"newline"` + DeleteBackward []string `toml:"delete_backward"` + DeleteForward []string `toml:"delete_forward"` + Clear []string `toml:"clear"` + NextField []string `toml:"next_field"` + PreviousField []string `toml:"previous_field"` + NextCompletion []string `toml:"next_completion"` + PreviousCompletion []string `toml:"previous_completion"` + LineStart []string `toml:"line_start"` + LineEnd []string `toml:"line_end"` +} + +type VimKeyBindings struct { + Insert []string `toml:"insert"` + Append []string `toml:"append"` + InsertLineStart []string `toml:"insert_line_start"` + AppendLineEnd []string `toml:"append_line_end"` + OpenBelow []string `toml:"open_below"` + OpenAbove []string `toml:"open_above"` + ReplaceCharacter []string `toml:"replace_character"` + Visual []string `toml:"visual"` + VisualLine []string `toml:"visual_line"` + SelectionOtherEnd []string `toml:"selection_other_end"` + Yank []string `toml:"yank"` + Delete []string `toml:"delete"` + DeleteBefore []string `toml:"delete_before"` + Paste []string `toml:"paste"` + LineStart []string `toml:"line_start"` + FirstNonBlank []string `toml:"first_non_blank"` + LineEnd []string `toml:"line_end"` + WordForward []string `toml:"word_forward"` + WORDForward []string `toml:"big_word_forward"` + WordBackward []string `toml:"word_backward"` + WORDBackward []string `toml:"big_word_backward"` + WordEnd []string `toml:"word_end"` + WORDEnd []string `toml:"big_word_end"` + GoPrefix []string `toml:"go_prefix"` + FindForward []string `toml:"find_forward"` + FindBackward []string `toml:"find_backward"` + TillForward []string `toml:"till_forward"` + TillBackward []string `toml:"till_backward"` + RepeatFind []string `toml:"repeat_find"` + RepeatFindReverse []string `toml:"repeat_find_reverse"` +} + +func defaultKeyBindings() KeyBindings { + return KeyBindings{ + General: GeneralKeyBindings{ + Quit: []string{"q", "ctrl+c"}, Help: []string{"?", "f1"}, + Refresh: []string{"r"}, Back: []string{"b", "esc"}, + Confirm: []string{"y"}, Reject: []string{"n", "esc"}, + }, + Navigation: NavigationKeyBindings{ + Down: []string{"j", "down"}, Up: []string{"k", "up"}, + Left: []string{"h", "left"}, Right: []string{"l", "right"}, + First: []string{"g"}, Last: []string{"G"}, + PageDown: []string{"ctrl+d", "pgdown"}, PageUp: []string{"ctrl+u", "pgup"}, + }, + Views: ViewKeyBindings{ + Open: []string{"enter", "l"}, Dashboard: []string{"d"}, + Edit: []string{"e"}, ToggleList: []string{"tab"}, + }, + Threads: ThreadKeyBindings{ + Search: []string{"/"}, ClearFilter: []string{"F"}, + NextUnread: []string{"n"}, PreviousUnread: []string{"N"}, + Reply: []string{"c"}, Resolve: []string{"R"}, Toggle: []string{"enter"}, + FoldPrefix: []string{"z"}, FoldToggle: []string{"a"}, + }, + Input: InputKeyBindings{ + Cancel: []string{"esc"}, Submit: []string{"ctrl+s"}, Newline: []string{"enter"}, + DeleteBackward: []string{"backspace"}, DeleteForward: []string{"delete"}, + Clear: []string{"ctrl+u"}, NextField: []string{"tab"}, + PreviousField: []string{"shift+tab"}, NextCompletion: []string{"ctrl+n"}, + PreviousCompletion: []string{"ctrl+p"}, + LineStart: []string{"home", "ctrl+a"}, LineEnd: []string{"end", "ctrl+e"}, + }, + Vim: VimKeyBindings{ + Insert: []string{"i"}, Append: []string{"a"}, + InsertLineStart: []string{"I"}, AppendLineEnd: []string{"A"}, + OpenBelow: []string{"o"}, OpenAbove: []string{"O"}, + ReplaceCharacter: []string{"s"}, Visual: []string{"v"}, VisualLine: []string{"V"}, + SelectionOtherEnd: []string{"o"}, Yank: []string{"y"}, + Delete: []string{"d", "x", "delete"}, DeleteBefore: []string{"X", "backspace"}, + Paste: []string{"p"}, LineStart: []string{"0", "home"}, + FirstNonBlank: []string{"^"}, LineEnd: []string{"$", "end"}, + WordForward: []string{"w"}, WORDForward: []string{"W"}, + WordBackward: []string{"b"}, WORDBackward: []string{"B"}, + WordEnd: []string{"e"}, WORDEnd: []string{"E"}, GoPrefix: []string{"g"}, + FindForward: []string{"f"}, FindBackward: []string{"F"}, + TillForward: []string{"t"}, TillBackward: []string{"T"}, + RepeatFind: []string{";"}, RepeatFindReverse: []string{","}, + }, + } +} + +func keyMatches(key string, bindings []string) bool { + for _, binding := range bindings { + if key == binding { + return true + } + } + return false +} + +func keyLabel(bindings []string) string { + return strings.Join(bindings, " / ") +} + +func primaryKeyLabel(bindings []string) string { + if len(bindings) == 0 { + return "" + } + return bindings[0] +} + +func primaryCombinedKeyLabel(groups ...[]string) string { + keys := make([]string, 0, len(groups)) + for _, group := range groups { + if key := primaryKeyLabel(group); key != "" { + keys = append(keys, key) + } + } + return strings.Join(keys, " / ") +} + +func primarySequenceKeyLabel(prefixes, suffixes []string) string { + return primaryKeyLabel(prefixes) + primaryKeyLabel(suffixes) +} + +func combinedKeyLabel(groups ...[]string) string { + var keys []string + for _, group := range groups { + keys = append(keys, group...) + } + return keyLabel(keys) +} + +func sequenceKeyLabel(prefixes, suffixes []string) string { + var sequences []string + for _, prefix := range prefixes { + for _, suffix := range suffixes { + sequences = append(sequences, prefix+suffix) + } + } + return keyLabel(sequences) +} + +func (k KeyBindings) canonicalHelpKey(key string) string { + switch { + case keyMatches(key, k.General.Quit): + return "ctrl+c" + case keyMatches(key, k.General.Help), keyMatches(key, k.General.Back): + return "esc" + case keyMatches(key, k.Navigation.Down): + return "j" + case keyMatches(key, k.Navigation.Up): + return "k" + case keyMatches(key, k.Navigation.First): + return "g" + case keyMatches(key, k.Navigation.Last): + return "G" + case keyMatches(key, k.Navigation.PageDown): + return "ctrl+d" + case keyMatches(key, k.Navigation.PageUp): + return "ctrl+u" + default: + return "" + } +} + +func (k KeyBindings) canonicalSearchKey(key string) string { + switch { + case keyMatches(key, k.Input.Cancel): + return "esc" + case keyMatches(key, k.Input.Newline): + return "enter" + case keyMatches(key, k.Input.PreviousCompletion): + return "up" + case keyMatches(key, k.Input.NextCompletion): + return "down" + case keyMatches(key, k.Input.DeleteBackward): + return "backspace" + case keyMatches(key, k.Input.Clear): + return "ctrl+u" + default: + return "" + } +} + +func (k KeyBindings) canonicalWriteKey(key string) string { + switch { + case keyMatches(key, k.Input.Cancel): + return "esc" + case keyMatches(key, k.General.Confirm): + return "y" + case keyMatches(key, k.General.Reject): + return "n" + case keyMatches(key, k.Input.Submit): + return "ctrl+s" + case keyMatches(key, k.Input.Newline): + return "enter" + case keyMatches(key, k.Input.DeleteBackward): + return "backspace" + default: + return "" + } +} + +func (k KeyBindings) canonicalMainKey(key string, current screen) string { + switch { + case keyMatches(key, k.General.Quit): + return "q" + case keyMatches(key, k.General.Help): + return "?" + case keyMatches(key, k.General.Refresh): + return "r" + case keyMatches(key, k.General.Back): + return "esc" + case keyMatches(key, k.Navigation.Down): + return "j" + case keyMatches(key, k.Navigation.Up): + return "k" + case keyMatches(key, k.Navigation.First): + return "g" + case keyMatches(key, k.Navigation.Last): + return "G" + case keyMatches(key, k.Navigation.PageDown): + return "ctrl+d" + case keyMatches(key, k.Navigation.PageUp): + return "ctrl+u" + } + if current == prScreen || current == dashboardScreen { + if keyMatches(key, k.Views.Open) { + return "enter" + } + } + if current == threadScreen { + switch { + case keyMatches(key, k.Navigation.Left): + return "h" + case keyMatches(key, k.Navigation.Right): + return "l" + case keyMatches(key, k.Views.ToggleList): + return "tab" + case keyMatches(key, k.Threads.Search): + return "/" + case keyMatches(key, k.Threads.ClearFilter): + return "F" + case keyMatches(key, k.Threads.NextUnread): + return "n" + case keyMatches(key, k.Threads.PreviousUnread): + return "N" + case keyMatches(key, k.Threads.Reply): + return "c" + case keyMatches(key, k.Threads.Resolve): + return "R" + case keyMatches(key, k.Threads.Toggle): + return "enter" + case keyMatches(key, k.Threads.FoldPrefix): + return "z" + } + } + switch { + case keyMatches(key, k.Views.Dashboard): + return "d" + case keyMatches(key, k.Views.Edit): + return "e" + default: + return "" + } +} + +func (k KeyBindings) canonicalPREditKey(key string, field int, confirming bool) string { + if confirming { + switch { + case keyMatches(key, k.General.Confirm): + return "y" + case keyMatches(key, k.General.Reject), keyMatches(key, k.Input.Cancel): + return "esc" + } + } + switch { + case keyMatches(key, k.Input.Cancel): + return "esc" + case keyMatches(key, k.Input.Submit): + return "ctrl+s" + case keyMatches(key, k.Input.NextField): + return "tab" + case keyMatches(key, k.Input.PreviousField): + return "shift+tab" + case keyMatches(key, k.Input.NextCompletion): + return "ctrl+n" + case keyMatches(key, k.Input.PreviousCompletion): + return "ctrl+p" + case keyMatches(key, k.Navigation.PageDown): + return "ctrl+d" + case keyMatches(key, k.Navigation.PageUp): + return "ctrl+u" + } + if field != prEditBodyField { + switch { + case keyMatches(key, k.Input.Newline): + return "enter" + } + } + return "" +} + +func validateKeyBindings(bindings KeyBindings) error { + groups := []struct { + name string + values map[string][]string + }{ + {"keybindings.general", map[string][]string{ + "quit": bindings.General.Quit, "help": bindings.General.Help, + "refresh": bindings.General.Refresh, "back": bindings.General.Back, + "confirm": bindings.General.Confirm, "reject": bindings.General.Reject, + }}, + {"keybindings.navigation", map[string][]string{ + "down": bindings.Navigation.Down, "up": bindings.Navigation.Up, + "left": bindings.Navigation.Left, "right": bindings.Navigation.Right, + "first": bindings.Navigation.First, "last": bindings.Navigation.Last, + "page_down": bindings.Navigation.PageDown, "page_up": bindings.Navigation.PageUp, + }}, + {"keybindings.views", map[string][]string{ + "open": bindings.Views.Open, "dashboard": bindings.Views.Dashboard, + "edit": bindings.Views.Edit, "toggle_list": bindings.Views.ToggleList, + }}, + {"keybindings.threads", map[string][]string{ + "search": bindings.Threads.Search, "clear_filter": bindings.Threads.ClearFilter, + "next_unread": bindings.Threads.NextUnread, + "previous_unread": bindings.Threads.PreviousUnread, + "reply": bindings.Threads.Reply, "resolve": bindings.Threads.Resolve, + "toggle": bindings.Threads.Toggle, "fold_prefix": bindings.Threads.FoldPrefix, + "fold_toggle": bindings.Threads.FoldToggle, + }}, + {"keybindings.input", map[string][]string{ + "cancel": bindings.Input.Cancel, + "submit": bindings.Input.Submit, "newline": bindings.Input.Newline, + "delete_backward": bindings.Input.DeleteBackward, + "delete_forward": bindings.Input.DeleteForward, "clear": bindings.Input.Clear, + "next_field": bindings.Input.NextField, "previous_field": bindings.Input.PreviousField, + "next_completion": bindings.Input.NextCompletion, + "previous_completion": bindings.Input.PreviousCompletion, + "line_start": bindings.Input.LineStart, "line_end": bindings.Input.LineEnd, + }}, + {"keybindings.vim", map[string][]string{ + "insert": bindings.Vim.Insert, "append": bindings.Vim.Append, + "insert_line_start": bindings.Vim.InsertLineStart, + "append_line_end": bindings.Vim.AppendLineEnd, + "open_below": bindings.Vim.OpenBelow, "open_above": bindings.Vim.OpenAbove, + "replace_character": bindings.Vim.ReplaceCharacter, + "visual": bindings.Vim.Visual, "visual_line": bindings.Vim.VisualLine, + "selection_other_end": bindings.Vim.SelectionOtherEnd, + "yank": bindings.Vim.Yank, "delete": bindings.Vim.Delete, + "delete_before": bindings.Vim.DeleteBefore, "paste": bindings.Vim.Paste, + "line_start": bindings.Vim.LineStart, + "first_non_blank": bindings.Vim.FirstNonBlank, "line_end": bindings.Vim.LineEnd, + "word_forward": bindings.Vim.WordForward, "big_word_forward": bindings.Vim.WORDForward, + "word_backward": bindings.Vim.WordBackward, "big_word_backward": bindings.Vim.WORDBackward, + "word_end": bindings.Vim.WordEnd, "big_word_end": bindings.Vim.WORDEnd, + "go_prefix": bindings.Vim.GoPrefix, + "find_forward": bindings.Vim.FindForward, "find_backward": bindings.Vim.FindBackward, + "till_forward": bindings.Vim.TillForward, "till_backward": bindings.Vim.TillBackward, + "repeat_find": bindings.Vim.RepeatFind, + "repeat_find_reverse": bindings.Vim.RepeatFindReverse, + }}, + } + for _, group := range groups { + for action, keys := range group.values { + if len(keys) == 0 { + return fmt.Errorf("%s.%s must contain at least one key", group.name, action) + } + seen := make(map[string]bool) + for _, key := range keys { + if strings.TrimSpace(key) == "" { + return fmt.Errorf("%s.%s contains an empty key", group.name, action) + } + if seen[key] { + return fmt.Errorf("%s.%s contains duplicate key %q", group.name, action, key) + } + seen[key] = true + } + } + } + return validateKeyBindingContexts(bindings) +} + +type contextBinding struct { + action string + keys []string +} + +func validateKeyBindingContexts(bindings KeyBindings) error { + navigation := bindings.Navigation + general := bindings.General + views := bindings.Views + threads := bindings.Threads + input := bindings.Input + vim := bindings.Vim + + screenCommon := []contextBinding{ + {"quit", general.Quit}, {"help", general.Help}, {"refresh", general.Refresh}, + {"back", general.Back}, {"down", navigation.Down}, {"up", navigation.Up}, + {"first", navigation.First}, {"last", navigation.Last}, + {"page_down", navigation.PageDown}, {"page_up", navigation.PageUp}, + } + if err := validateKeyContext("pull request list", append(screenCommon, + contextBinding{"open", views.Open}, + contextBinding{"dashboard", views.Dashboard}, + )...); err != nil { + return err + } + if err := validateKeyContext("dashboard", append(screenCommon, + contextBinding{"open", views.Open}, + contextBinding{"edit", views.Edit}, + )...); err != nil { + return err + } + if err := validateKeyContext("review threads", append(screenCommon, + contextBinding{"left", navigation.Left}, + contextBinding{"right", navigation.Right}, + contextBinding{"toggle_list", views.ToggleList}, + contextBinding{"dashboard", views.Dashboard}, + contextBinding{"search", threads.Search}, + contextBinding{"clear_filter", threads.ClearFilter}, + contextBinding{"next_unread", threads.NextUnread}, + contextBinding{"previous_unread", threads.PreviousUnread}, + contextBinding{"reply", threads.Reply}, + contextBinding{"resolve", threads.Resolve}, + contextBinding{"toggle", threads.Toggle}, + contextBinding{"fold_prefix", threads.FoldPrefix}, + )...); err != nil { + return err + } + if err := validateKeyContext("help popup", + contextBinding{"quit", general.Quit}, + contextBinding{"close_help", appendCopy(general.Help, general.Back...)}, + contextBinding{"down", navigation.Down}, + contextBinding{"up", navigation.Up}, + contextBinding{"first", navigation.First}, + contextBinding{"last", navigation.Last}, + contextBinding{"page_down", navigation.PageDown}, + contextBinding{"page_up", navigation.PageUp}, + ); err != nil { + return err + } + + if err := validateKeyContext("search input", + contextBinding{"quit", nonTextBindings(general.Quit)}, + contextBinding{"cancel", input.Cancel}, + contextBinding{"apply", input.Newline}, + contextBinding{"previous_completion", input.PreviousCompletion}, + contextBinding{"next_completion", input.NextCompletion}, + contextBinding{"delete_backward", input.DeleteBackward}, + contextBinding{"clear", input.Clear}, + ); err != nil { + return err + } + if err := validateKeyContext("reply input", + contextBinding{"quit", nonTextBindings(general.Quit)}, + contextBinding{"cancel", input.Cancel}, + contextBinding{"submit", input.Submit}, + contextBinding{"newline", input.Newline}, + contextBinding{"delete_backward", input.DeleteBackward}, + ); err != nil { + return err + } + if err := validateKeyContext("confirmation", + contextBinding{"quit", nonTextBindings(general.Quit)}, + contextBinding{"confirm", general.Confirm}, + contextBinding{"cancel", appendCopy(general.Reject, input.Cancel...)}, + ); err != nil { + return err + } + + editorOuter := []contextBinding{ + {"help", general.Help}, + {"cancel", input.Cancel}, + {"submit", input.Submit}, + {"next_field", input.NextField}, + {"previous_field", input.PreviousField}, + {"page_down", navigation.PageDown}, + {"page_up", navigation.PageUp}, + } + normalEditor := append(append([]contextBinding(nil), editorOuter...), + contextBinding{"left", navigation.Left}, + contextBinding{"down", navigation.Down}, + contextBinding{"up", navigation.Up}, + contextBinding{"right", navigation.Right}, + contextBinding{"last", navigation.Last}, + contextBinding{"insert", vim.Insert}, + contextBinding{"append", vim.Append}, + contextBinding{"insert_line_start", vim.InsertLineStart}, + contextBinding{"append_line_end", vim.AppendLineEnd}, + contextBinding{"open_below", vim.OpenBelow}, + contextBinding{"open_above", vim.OpenAbove}, + contextBinding{"replace_character", vim.ReplaceCharacter}, + contextBinding{"visual", vim.Visual}, + contextBinding{"visual_line", vim.VisualLine}, + contextBinding{"paste", vim.Paste}, + contextBinding{"line_start", vim.LineStart}, + contextBinding{"first_non_blank", vim.FirstNonBlank}, + contextBinding{"line_end", vim.LineEnd}, + contextBinding{"word_forward", vim.WordForward}, + contextBinding{"big_word_forward", vim.WORDForward}, + contextBinding{"word_backward", vim.WordBackward}, + contextBinding{"big_word_backward", vim.WORDBackward}, + contextBinding{"word_end", vim.WordEnd}, + contextBinding{"big_word_end", vim.WORDEnd}, + contextBinding{"go_prefix", vim.GoPrefix}, + contextBinding{"find_forward", vim.FindForward}, + contextBinding{"find_backward", vim.FindBackward}, + contextBinding{"till_forward", vim.TillForward}, + contextBinding{"till_backward", vim.TillBackward}, + contextBinding{"repeat_find", vim.RepeatFind}, + contextBinding{"repeat_find_reverse", vim.RepeatFindReverse}, + contextBinding{"delete", vim.Delete}, + contextBinding{"delete_before", vim.DeleteBefore}, + ) + if err := validateKeyContext("Vim Normal mode", normalEditor...); err != nil { + return err + } + visualEditor := append(append([]contextBinding(nil), editorOuter...), + contextBinding{"left", navigation.Left}, + contextBinding{"down", navigation.Down}, + contextBinding{"up", navigation.Up}, + contextBinding{"right", navigation.Right}, + contextBinding{"last", navigation.Last}, + contextBinding{"visual", vim.Visual}, + contextBinding{"visual_line", vim.VisualLine}, + contextBinding{"selection_other_end", vim.SelectionOtherEnd}, + contextBinding{"yank", vim.Yank}, + contextBinding{"delete", vim.Delete}, + contextBinding{"paste", vim.Paste}, + contextBinding{"line_start", vim.LineStart}, + contextBinding{"first_non_blank", vim.FirstNonBlank}, + contextBinding{"line_end", vim.LineEnd}, + contextBinding{"word_forward", vim.WordForward}, + contextBinding{"big_word_forward", vim.WORDForward}, + contextBinding{"word_backward", vim.WordBackward}, + contextBinding{"big_word_backward", vim.WORDBackward}, + contextBinding{"word_end", vim.WordEnd}, + contextBinding{"big_word_end", vim.WORDEnd}, + contextBinding{"go_prefix", vim.GoPrefix}, + contextBinding{"find_forward", vim.FindForward}, + contextBinding{"find_backward", vim.FindBackward}, + contextBinding{"till_forward", vim.TillForward}, + contextBinding{"till_backward", vim.TillBackward}, + contextBinding{"repeat_find", vim.RepeatFind}, + contextBinding{"repeat_find_reverse", vim.RepeatFindReverse}, + ) + if err := validateKeyContext("Vim Visual mode", visualEditor...); err != nil { + return err + } + insertEditor := append(append([]contextBinding(nil), editorOuter...), + contextBinding{"left", nonTextBindings(navigation.Left)}, + contextBinding{"down", nonTextBindings(navigation.Down)}, + contextBinding{"up", nonTextBindings(navigation.Up)}, + contextBinding{"right", nonTextBindings(navigation.Right)}, + contextBinding{"newline", input.Newline}, + contextBinding{"delete_backward", input.DeleteBackward}, + contextBinding{"delete_forward", input.DeleteForward}, + contextBinding{"line_start", input.LineStart}, + contextBinding{"line_end", input.LineEnd}, + ) + if err := validateKeyContext("editor Insert mode", insertEditor...); err != nil { + return err + } + return validateKeyContext("single-line editor input", + contextBinding{"help", nonTextBindings(general.Help)}, + contextBinding{"cancel", input.Cancel}, + contextBinding{"submit", input.Submit}, + contextBinding{"next_field", input.NextField}, + contextBinding{"previous_field", input.PreviousField}, + contextBinding{"previous_completion", input.PreviousCompletion}, + contextBinding{"next_completion", input.NextCompletion}, + contextBinding{"newline", input.Newline}, + contextBinding{"delete_backward", input.DeleteBackward}, + contextBinding{"delete_forward", input.DeleteForward}, + contextBinding{"line_start", input.LineStart}, + contextBinding{"line_end", input.LineEnd}, + contextBinding{"up", nonTextBindings(navigation.Up)}, + contextBinding{"down", nonTextBindings(navigation.Down)}, + ) +} + +func validateKeyContext(context string, bindings ...contextBinding) error { + assigned := make(map[string]string) + for _, binding := range bindings { + for _, key := range binding.keys { + if previous, exists := assigned[key]; exists && previous != binding.action { + return fmt.Errorf( + "keybinding conflict in %s: key %q is assigned to both %s and %s", + context, key, previous, binding.action, + ) + } + assigned[key] = binding.action + } + } + return nil +} + +func nonTextBindings(bindings []string) []string { + var filtered []string + for _, binding := range bindings { + if utf8.RuneCountInString(binding) != 1 { + filtered = append(filtered, binding) + } + } + return filtered +} + +func appendCopy(bindings []string, more ...string) []string { + result := append([]string(nil), bindings...) + return append(result, more...) +} diff --git a/main.go b/main.go index 1af8543..5356405 100644 --- a/main.go +++ b/main.go @@ -139,6 +139,7 @@ func main() { ThreadStatusOrder: config.Threads.StatusOrder, ThreadWithinStatus: config.Threads.WithinStatus, EditorMode: config.Editing.Mode, + KeyBindings: config.KeyBindings, }, ) cursorOutput := newTerminalCursorOutput(os.Stdout) diff --git a/pr_editor.go b/pr_editor.go index 71167e6..488c2d6 100644 --- a/pr_editor.go +++ b/pr_editor.go @@ -34,6 +34,7 @@ func (m *App) startPREdit() tea.Cmd { m.prEditEditors[prEditBodyField].highlightMarkdown = true for index := range m.prEditEditors { m.prEditEditors[index].hardwareCursor = m.cursorOutput != nil + m.prEditEditors[index].keys = m.keybindings } m.prEditOriginal = m.currentPRMetadata() m.prEditBranches = nil @@ -83,7 +84,18 @@ func (m App) pullRequestUpdateUnavailable() string { } func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) { - k := key.String() + k := m.keybindings.canonicalPREditKey( + key.String(), m.prEditField, m.writeMode == writePREditConfirm, + ) + if m.prEditField != prEditBodyField && + key.Type != tea.KeyRunes && key.Type != tea.KeySpace { + switch { + case keyMatches(key.String(), m.keybindings.Navigation.Up): + k = "up" + case keyMatches(key.String(), m.keybindings.Navigation.Down): + k = "down" + } + } editorWidth := m.prEditEditorWidth() if m.writeMode == writePREditConfirm { switch k { @@ -375,17 +387,6 @@ func (m App) dashboardEditLayout() ([]string, int) { appendField("title", prEditTitleField) appendField("target branch", prEditBaseField) appendField("description", prEditBodyField) - help := "tab/shift-tab field • ctrl-s review • esc normal/cancel" - if m.prEditEditors[prEditBodyField].Modal { - help += " • i/a/s insert • v/V select • y copy • p paste • hjkl/wWbBeE move • ctrl-d/u page • fFtT + ; find" - } else { - help += " • arrows/home/end move • ctrl-d/u page • enter newline" - } - wrapped := ansi.Hardwrap(ansi.Wordwrap(help, width, ""), width, false) - lines = append(lines, "") - for _, line := range strings.Split(wrapped, "\n") { - lines = append(lines, dimStyle.Render(line)) - } return lines, cursorLine } @@ -467,6 +468,10 @@ func (m App) prEditConfirmationLines(width int) []string { len([]rune(m.prEditOriginal.Body)), len([]rune(update.Body)), ), "") } - lines = append(lines, warnStyle.Render("y submit • n/esc continue editing")) + lines = append(lines, warnStyle.Render(fmt.Sprintf( + "%s submit • %s continue editing", + primaryKeyLabel(m.keybindings.General.Confirm), + primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel), + ))) return lines } diff --git a/text_editor.go b/text_editor.go index 27bb27b..b7f958e 100644 --- a/text_editor.go +++ b/text_editor.go @@ -39,12 +39,13 @@ type textEditor struct { err error hardwareCursor bool highlightMarkdown bool + keys KeyBindings } func newTextEditor(text string, modal bool) textEditor { editor := textEditor{ Text: text, Modal: modal, Mode: textEditorInsert, - clipboard: systemTextClipboard{}, + clipboard: systemTextClipboard{}, keys: defaultKeyBindings(), } editor.Cursor = len([]rune(text)) if modal { @@ -63,7 +64,7 @@ func (e *textEditor) handleKeyAtWidth(key tea.KeyMsg, multiline bool, wrapWidth return e.handleStandardKey(key, multiline, wrapWidth) } if e.Mode == textEditorInsert { - if key.String() == "esc" { + if keyMatches(key.String(), e.keys.Input.Cancel) { e.Mode = textEditorNormal e.clearPending() start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth) @@ -87,30 +88,35 @@ func (e *textEditor) movePage(delta, wrapWidth int) { } func (e *textEditor) handleStandardKey(key tea.KeyMsg, multiline bool, wrapWidth int) bool { - switch key.String() { - case "left": + k := key.String() + switch { + case key.Type != tea.KeyRunes && key.Type != tea.KeySpace && + keyMatches(k, e.keys.Navigation.Left): e.Cursor = max(0, e.Cursor-1) - case "right": + case key.Type != tea.KeyRunes && key.Type != tea.KeySpace && + keyMatches(k, e.keys.Navigation.Right): e.Cursor = min(len([]rune(e.Text)), e.Cursor+1) - case "up": + case key.Type != tea.KeyRunes && key.Type != tea.KeySpace && + keyMatches(k, e.keys.Navigation.Up): if !multiline { return false } e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, -1, wrapWidth, false) - case "down": + case key.Type != tea.KeyRunes && key.Type != tea.KeySpace && + keyMatches(k, e.keys.Navigation.Down): if !multiline { return false } e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, 1, wrapWidth, false) - case "home", "ctrl+a": + case keyMatches(k, e.keys.Input.LineStart): e.Cursor, _ = editorLineBounds(e.Text, e.Cursor, wrapWidth) - case "end", "ctrl+e": + case keyMatches(k, e.keys.Input.LineEnd): _, e.Cursor = editorLineBounds(e.Text, e.Cursor, wrapWidth) - case "backspace": + case keyMatches(k, e.keys.Input.DeleteBackward): e.deleteBefore() - case "delete": + case keyMatches(k, e.keys.Input.DeleteForward): e.deleteAt() - case "enter": + case keyMatches(k, e.keys.Input.Newline): if !multiline { return false } @@ -145,36 +151,37 @@ func (e *textEditor) handleNormalKey(key tea.KeyMsg, multiline bool, wrapWidth i } if e.pendingG { e.pendingG = false - if key.String() == "g" { + if keyMatches(key.String(), e.keys.Vim.GoPrefix) { e.Cursor = firstNonBlank(e.Text, 0) return true } } - switch key.String() { - case "esc": + k := key.String() + switch { + case keyMatches(k, e.keys.Input.Cancel): e.clearPending() return false - case "i": + case keyMatches(k, e.keys.Vim.Insert): e.Mode = textEditorInsert - case "a": + case keyMatches(k, e.keys.Vim.Append): _, end := editorLineBounds(e.Text, e.Cursor, wrapWidth) e.Cursor = min(end, e.Cursor+1) e.Mode = textEditorInsert - case "I": + case keyMatches(k, e.keys.Vim.InsertLineStart): e.Cursor = firstNonBlankAtWidth(e.Text, e.Cursor, wrapWidth) e.Mode = textEditorInsert - case "A": + case keyMatches(k, e.keys.Vim.AppendLineEnd): _, e.Cursor = editorLineBounds(e.Text, e.Cursor, wrapWidth) e.Mode = textEditorInsert - case "o": + case keyMatches(k, e.keys.Vim.OpenBelow): if !multiline { return false } _, e.Cursor = editorLineBounds(e.Text, e.Cursor, wrapWidth) e.insert("\n") e.Mode = textEditorInsert - case "O": + case keyMatches(k, e.keys.Vim.OpenAbove): if !multiline { return false } @@ -183,69 +190,75 @@ func (e *textEditor) handleNormalKey(key tea.KeyMsg, multiline bool, wrapWidth i e.insert("\n") e.Cursor = start e.Mode = textEditorInsert - case "h", "left": + case keyMatches(k, e.keys.Navigation.Left): start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth) e.Cursor = max(start, e.Cursor-1) - case "l", "right": + case keyMatches(k, e.keys.Navigation.Right): e.Cursor = min(normalEditorLineLast(e.Text, e.Cursor, wrapWidth), e.Cursor+1) - case "j", "down": + case keyMatches(k, e.keys.Navigation.Down): if multiline { e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, 1, wrapWidth, true) } - case "k", "up": + case keyMatches(k, e.keys.Navigation.Up): if multiline { e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, -1, wrapWidth, true) } - case "0", "home": + case keyMatches(k, e.keys.Vim.LineStart): e.Cursor, _ = editorLineBounds(e.Text, e.Cursor, wrapWidth) - case "^": + case keyMatches(k, e.keys.Vim.FirstNonBlank): e.Cursor = firstNonBlankAtWidth(e.Text, e.Cursor, wrapWidth) - case "$", "end": + case keyMatches(k, e.keys.Vim.LineEnd): e.Cursor = normalEditorLineLast(e.Text, e.Cursor, wrapWidth) - case "w": + case keyMatches(k, e.keys.Vim.WordForward): e.Cursor = nextWordStart(e.Text, e.Cursor, false) - case "W": + case keyMatches(k, e.keys.Vim.WORDForward): e.Cursor = nextWordStart(e.Text, e.Cursor, true) - case "b": + case keyMatches(k, e.keys.Vim.WordBackward): e.Cursor = previousWordStart(e.Text, e.Cursor, false) - case "B": + case keyMatches(k, e.keys.Vim.WORDBackward): e.Cursor = previousWordStart(e.Text, e.Cursor, true) - case "e": + case keyMatches(k, e.keys.Vim.WordEnd): e.Cursor = wordEndAtWidth(e.Text, e.Cursor, false, wrapWidth) - case "E": + case keyMatches(k, e.keys.Vim.WORDEnd): e.Cursor = wordEndAtWidth(e.Text, e.Cursor, true, wrapWidth) - case "g": + case keyMatches(k, e.keys.Vim.GoPrefix): e.pendingG = true - case "G": + case keyMatches(k, e.keys.Navigation.Last): e.Cursor = firstNonBlank(e.Text, len([]rune(e.Text))) - case "v": + case keyMatches(k, e.keys.Vim.Visual): e.startVisual(false) - case "V": + case keyMatches(k, e.keys.Vim.VisualLine): e.startVisual(true) - case "p": + case keyMatches(k, e.keys.Vim.Paste): e.pasteClipboard(false, wrapWidth) - case "s": + case keyMatches(k, e.keys.Vim.ReplaceCharacter): _, end := editorLineBounds(e.Text, e.Cursor, wrapWidth) if e.Cursor < end { e.deleteAt() } e.Mode = textEditorInsert - case "f", "F", "t", "T": - e.pendingFind = []rune(key.String())[0] - case ";": + case keyMatches(k, e.keys.Vim.FindForward): + e.pendingFind = 'f' + case keyMatches(k, e.keys.Vim.FindBackward): + e.pendingFind = 'F' + case keyMatches(k, e.keys.Vim.TillForward): + e.pendingFind = 't' + case keyMatches(k, e.keys.Vim.TillBackward): + e.pendingFind = 'T' + case keyMatches(k, e.keys.Vim.RepeatFind): if e.lastFind.valid { e.performFind(e.lastFind.command, e.lastFind.target, false, wrapWidth) } - case ",": + case keyMatches(k, e.keys.Vim.RepeatFindReverse): if e.lastFind.valid { e.performFind(reverseFind(e.lastFind.command), e.lastFind.target, false, wrapWidth) } - case "x", "delete": + case keyMatches(k, e.keys.Vim.Delete): _, end := editorLineBounds(e.Text, e.Cursor, wrapWidth) if e.Cursor < end { e.deleteAt() } - case "X", "backspace": + case keyMatches(k, e.keys.Vim.DeleteBefore): start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth) if e.Cursor > start { e.deleteBefore() @@ -263,25 +276,26 @@ func (e *textEditor) handleVisualKey(key tea.KeyMsg, multiline bool, wrapWidth i e.Mode = textEditorVisual return handled } - switch key.String() { - case "esc", "v": + k := key.String() + switch { + case keyMatches(k, e.keys.Input.Cancel), keyMatches(k, e.keys.Vim.Visual): e.stopVisual() - case "V": + case keyMatches(k, e.keys.Vim.VisualLine): if e.visualLine { e.stopVisual() } else { e.visualLine = true } - case "o": + case keyMatches(k, e.keys.Vim.SelectionOtherEnd): e.Cursor, e.visualAnchor = e.visualAnchor, e.Cursor - case "y": + case keyMatches(k, e.keys.Vim.Yank): e.yankSelection(wrapWidth) - case "d", "x", "delete": + case keyMatches(k, e.keys.Vim.Delete): e.deleteSelection(wrapWidth) - case "p": + case keyMatches(k, e.keys.Vim.Paste): e.pasteClipboard(true, wrapWidth) default: - if !isVisualMotion(key.String()) { + if !e.isVisualMotion(k) { return false } e.Mode = textEditorNormal @@ -292,16 +306,24 @@ func (e *textEditor) handleVisualKey(key tea.KeyMsg, multiline bool, wrapWidth i return true } -func isVisualMotion(key string) bool { - switch key { - case "h", "j", "k", "l", "left", "down", "up", "right", - "0", "^", "$", "home", "end", - "w", "W", "b", "B", "e", "E", "g", "G", - "f", "F", "t", "T", ";", ",": - return true - default: - return false +func (e textEditor) isVisualMotion(key string) bool { + groups := [][]string{ + e.keys.Navigation.Left, e.keys.Navigation.Down, e.keys.Navigation.Up, + e.keys.Navigation.Right, e.keys.Navigation.Last, + e.keys.Vim.LineStart, e.keys.Vim.FirstNonBlank, e.keys.Vim.LineEnd, + e.keys.Vim.WordForward, e.keys.Vim.WORDForward, + e.keys.Vim.WordBackward, e.keys.Vim.WORDBackward, + e.keys.Vim.WordEnd, e.keys.Vim.WORDEnd, e.keys.Vim.GoPrefix, + e.keys.Vim.FindForward, e.keys.Vim.FindBackward, + e.keys.Vim.TillForward, e.keys.Vim.TillBackward, + e.keys.Vim.RepeatFind, e.keys.Vim.RepeatFindReverse, } + for _, group := range groups { + if keyMatches(key, group) { + return true + } + } + return false } func (e *textEditor) startVisual(linewise bool) { diff --git a/text_editor_test.go b/text_editor_test.go index 6833c4e..a938635 100644 --- a/text_editor_test.go +++ b/text_editor_test.go @@ -16,6 +16,21 @@ type memoryTextClipboard struct { writeErr error } +func TestVimEditorUsesSharedConfiguredNavigation(t *testing.T) { + editor := newTextEditor("first\nsecond", true) + editor.keys.Navigation.Down = []string{"ctrl+j"} + editor.keys.Navigation.Up = []string{"ctrl+k"} + + editor.handleKey(runeKey("j"), true) + if editor.Cursor != 0 { + t.Fatalf("removed default j moved cursor to %d", editor.Cursor) + } + editor.handleKey(tea.KeyMsg{Type: tea.KeyCtrlJ}, true) + if editor.Cursor != len([]rune("first\n")) { + t.Fatalf("configured ctrl+j moved cursor to %d", editor.Cursor) + } +} + func (c *memoryTextClipboard) ReadText() (string, error) { return c.text, c.readErr } diff --git a/tui.go b/tui.go index 000555c..ed36f1d 100644 --- a/tui.go +++ b/tui.go @@ -135,6 +135,7 @@ type App struct { dashboardReturn screen compactReviews bool editorMode string + keybindings KeyBindings readState *readStateStore knownThreads map[string]bool knownComments map[string]bool @@ -153,6 +154,7 @@ type AppSettings struct { DashboardMode string CompactReviews bool EditorMode string + KeyBindings KeyBindings ReadState *readStateStore } @@ -166,6 +168,7 @@ func defaultAppSettings() AppSettings { DashboardMode: "hotkey", CompactReviews: true, EditorMode: "vim", + KeyBindings: defaultKeyBindings(), } } @@ -195,6 +198,7 @@ func NewAppWithSettings( dashboardMode: settings.DashboardMode, dashboardReturn: prScreen, compactReviews: settings.CompactReviews, editorMode: settings.EditorMode, + keybindings: settings.KeyBindings, readState: state, knownThreads: make(map[string]bool), knownComments: make(map[string]bool), initializedPRs: make(map[string]bool), unreadThreads: make(map[string]bool), @@ -328,10 +332,11 @@ func (m App) writeActionUnavailable(action string, thread *ReviewThread) string } func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) { - k := key.String() - if k == "ctrl+c" { + if key.Type != tea.KeyRunes && key.Type != tea.KeySpace && + keyMatches(key.String(), m.keybindings.General.Quit) { return m, tea.Quit } + k := m.keybindings.canonicalWriteKey(key.String()) switch m.writeMode { case writeReply: switch k { @@ -611,10 +616,8 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } k := key.String() - if m.writeMode != writeNone { - return m.updateWriteInput(key) - } if m.helpVisible { + k = m.keybindings.canonicalHelpKey(k) switch k { case "ctrl+c": return m, tea.Quit @@ -635,7 +638,25 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } + if m.writeMode == writePREdit || m.writeMode == writePREditConfirm { + editor := m.prEditEditors[m.prEditField] + helpOutsideTextInput := key.Type != tea.KeyRunes && key.Type != tea.KeySpace + helpInVimNormalMode := editor.Modal && editor.Mode == textEditorNormal + if keyMatches(k, m.keybindings.General.Help) && + (helpOutsideTextInput || helpInVimNormalMode) { + m.helpVisible, m.helpScroll = true, 0 + return m, nil + } + } + if m.writeMode != writeNone { + return m.updateWriteInput(key) + } if m.searching { + if key.Type != tea.KeyRunes && key.Type != tea.KeySpace && + keyMatches(k, m.keybindings.General.Quit) { + return m, tea.Quit + } + k = m.keybindings.canonicalSearchKey(k) switch k { case "ctrl+c": return m, tea.Quit @@ -667,6 +688,8 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } + rawKey := k + k = m.keybindings.canonicalMainKey(k, m.screen) if k == "ctrl+c" || k == "q" { return m, tea.Quit } @@ -676,7 +699,8 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.pendingZ { m.pendingZ = false - if k == "a" && m.screen == threadScreen && len(m.details.Threads) > 0 { + if keyMatches(rawKey, m.keybindings.Threads.FoldToggle) && + m.screen == threadScreen && len(m.details.Threads) > 0 { thread := m.details.Threads[m.threadIndex] m.folded[thread.ID] = !m.folded[thread.ID] m.scroll = 0 @@ -1227,15 +1251,15 @@ func (m App) View() string { if m.width == 0 { return "Loading…" } + if m.helpVisible { + return m.viewHelp() + } if m.writeMode != writeNone && m.writeMode != writeReply { if m.writeMode == writePREdit { return m.viewDashboard() } return m.viewWritePopup() } - if m.helpVisible { - return m.viewHelp() - } if m.screen == prScreen { return m.viewPRs() } @@ -1267,11 +1291,20 @@ func (m App) viewWritePopup() string { if m.err != nil { lines = append(lines, "", badStyle.Render(m.err.Error())) } - lines = append(lines, "", dimStyle.Render("enter newline • ctrl-s review • esc cancel")) + lines = append(lines, "", dimStyle.Render(fmt.Sprintf( + "%s newline • %s review • %s cancel", + primaryKeyLabel(m.keybindings.Input.Newline), + primaryKeyLabel(m.keybindings.Input.Submit), + primaryKeyLabel(m.keybindings.Input.Cancel), + ))) case writeReplyConfirm: lines = []string{titleStyle.Render("Submit this reply to " + location + "?"), ""} lines = append(lines, renderCommentMarkdown(m.replyDraft, width-2)...) - lines = append(lines, "", warnStyle.Render("y submit • n/esc continue editing")) + lines = append(lines, "", warnStyle.Render(fmt.Sprintf( + "%s submit • %s continue editing", + primaryKeyLabel(m.keybindings.General.Confirm), + primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel), + ))) case writeReplyBusy: lines = []string{titleStyle.Render("Submitting reply…"), "", dimStyle.Render(location)} case writeResolveConfirm: @@ -1282,7 +1315,11 @@ func (m App) viewWritePopup() string { lines = []string{ titleStyle.Render(strings.ToUpper(action[:1]) + action[1:] + " " + location + "?"), "", - warnStyle.Render("y confirm • n/esc cancel"), + warnStyle.Render(fmt.Sprintf( + "%s confirm • %s cancel", + primaryKeyLabel(m.keybindings.General.Confirm), + primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel), + )), } case writeResolveBusy: action := "Resolving" @@ -1322,21 +1359,94 @@ type helpBinding struct { } func (m App) helpBindings() []helpBinding { + if m.writeMode == writePREdit || m.writeMode == writePREditConfirm { + bindings := []helpBinding{ + {combinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.PreviousField), "Move to the next / previous field"}, + {keyLabel(m.keybindings.Input.Submit), "Review pull request metadata changes"}, + {keyLabel(m.keybindings.Input.Cancel), "Return to Normal mode or cancel the editor"}, + {combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Move through the description by half a page"}, + {combinedKeyLabel(m.keybindings.Input.PreviousCompletion, m.keybindings.Input.NextCompletion), "Select the previous / next target-branch completion"}, + {combinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.Newline), "Complete the selected target branch"}, + } + if m.prEditEditors[prEditBodyField].Modal { + bindings = append(bindings, + helpBinding{combinedKeyLabel( + m.keybindings.Navigation.Left, m.keybindings.Navigation.Down, + m.keybindings.Navigation.Up, m.keybindings.Navigation.Right, + ), "Move left / down / up / right in Normal or Visual mode"}, + helpBinding{combinedKeyLabel( + m.keybindings.Vim.WordForward, m.keybindings.Vim.WORDForward, + m.keybindings.Vim.WordBackward, m.keybindings.Vim.WORDBackward, + m.keybindings.Vim.WordEnd, m.keybindings.Vim.WORDEnd, + ), "Move by words or WORDs"}, + helpBinding{combinedKeyLabel( + m.keybindings.Vim.LineStart, m.keybindings.Vim.FirstNonBlank, + m.keybindings.Vim.LineEnd, + ), "Move to the line start, first non-blank, or line end"}, + helpBinding{combinedKeyLabel( + m.keybindings.Vim.GoPrefix, m.keybindings.Navigation.Last, + ), "Move to the start / end of the description"}, + helpBinding{combinedKeyLabel( + m.keybindings.Vim.Insert, m.keybindings.Vim.Append, + m.keybindings.Vim.InsertLineStart, m.keybindings.Vim.AppendLineEnd, + m.keybindings.Vim.ReplaceCharacter, + ), "Enter Insert mode"}, + helpBinding{combinedKeyLabel( + m.keybindings.Vim.OpenBelow, m.keybindings.Vim.OpenAbove, + ), "Open a line below / above"}, + helpBinding{combinedKeyLabel( + m.keybindings.Vim.Visual, m.keybindings.Vim.VisualLine, + ), "Start character-wise / line-wise Visual mode"}, + helpBinding{keyLabel(m.keybindings.Vim.SelectionOtherEnd), "Move to the other end of a Visual selection"}, + helpBinding{keyLabel(m.keybindings.Vim.Yank), "Copy the Visual selection to the system clipboard"}, + helpBinding{keyLabel(m.keybindings.Vim.Paste), "Paste from the system clipboard"}, + helpBinding{combinedKeyLabel( + m.keybindings.Vim.Delete, m.keybindings.Vim.DeleteBefore, + ), "Delete the selection or text at / before the cursor"}, + helpBinding{combinedKeyLabel( + m.keybindings.Vim.FindForward, m.keybindings.Vim.FindBackward, + m.keybindings.Vim.TillForward, m.keybindings.Vim.TillBackward, + ), "Find or move until a character on the current visual line"}, + helpBinding{combinedKeyLabel( + m.keybindings.Vim.RepeatFind, m.keybindings.Vim.RepeatFindReverse, + ), "Repeat the last character find forward / backward"}, + ) + } else { + bindings = append(bindings, + helpBinding{combinedKeyLabel( + m.keybindings.Navigation.Left, m.keybindings.Navigation.Down, + m.keybindings.Navigation.Up, m.keybindings.Navigation.Right, + ), "Move the description cursor"}, + helpBinding{combinedKeyLabel( + m.keybindings.Input.LineStart, m.keybindings.Input.LineEnd, + ), "Move to the start / end of the current visual line"}, + helpBinding{keyLabel(m.keybindings.Input.Newline), "Insert a newline"}, + helpBinding{combinedKeyLabel( + m.keybindings.Input.DeleteBackward, m.keybindings.Input.DeleteForward, + ), "Delete text before / at the cursor"}, + ) + } + bindings = append(bindings, + helpBinding{keyLabel(m.keybindings.General.Help), "Close this help"}, + helpBinding{keyLabel(m.keybindings.General.Quit), "Quit"}, + ) + return bindings + } if m.screen == prScreen { openAction := "Open pull request dashboard" if m.dashboardMode == "hotkey" { openAction = "Open review threads" } return []helpBinding{ - {"j / ↓", "Next pull request"}, - {"k / ↑", "Previous pull request"}, - {"g / G", "First / last pull request"}, - {"ctrl-d / ctrl-u", "Page down / up"}, - {"enter / l", openAction}, - {"d", "Open pull request dashboard"}, - {"r", "Refresh now"}, - {"?", "Close this help"}, - {"q / ctrl-c", "Quit"}, + {keyLabel(m.keybindings.Navigation.Down), "Next pull request"}, + {keyLabel(m.keybindings.Navigation.Up), "Previous pull request"}, + {combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "First / last pull request"}, + {combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"}, + {keyLabel(m.keybindings.Views.Open), openAction}, + {keyLabel(m.keybindings.Views.Dashboard), "Open pull request dashboard"}, + {keyLabel(m.keybindings.General.Refresh), "Refresh now"}, + {keyLabel(m.keybindings.General.Help), "Close this help"}, + {keyLabel(m.keybindings.General.Quit), "Quit"}, } } if m.screen == dashboardScreen { @@ -1345,16 +1455,16 @@ func (m App) helpBindings() []helpBinding { backAction = "Return to review threads" } return []helpBinding{ - {"j / ↓", "Scroll description down"}, - {"k / ↑", "Scroll description up"}, - {"g / G", "Top / bottom"}, - {"ctrl-d / ctrl-u", "Page down / up"}, - {"e", "Edit title, target branch, and description"}, - {"enter / l", "Open review threads"}, - {"b / esc", backAction}, - {"r", "Refresh now"}, - {"?", "Close this help"}, - {"q / ctrl-c", "Quit"}, + {keyLabel(m.keybindings.Navigation.Down), "Scroll description down"}, + {keyLabel(m.keybindings.Navigation.Up), "Scroll description up"}, + {combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "Top / bottom"}, + {combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"}, + {keyLabel(m.keybindings.Views.Edit), "Edit title, target branch, and description"}, + {keyLabel(m.keybindings.Views.Open), "Open review threads"}, + {keyLabel(m.keybindings.General.Back), backAction}, + {keyLabel(m.keybindings.General.Refresh), "Refresh now"}, + {keyLabel(m.keybindings.General.Help), "Close this help"}, + {keyLabel(m.keybindings.General.Quit), "Quit"}, } } backAction := "Return to pull requests" @@ -1362,31 +1472,34 @@ func (m App) helpBindings() []helpBinding { backAction = "Return to PR dashboard" } bindings := []helpBinding{ - {"h / l", "Focus thread list / detail"}, - {"j / k", "Move or scroll focused pane"}, - {"↓ / ↑", "Move or scroll focused pane"}, - {"g / G", "First / last item"}, - {"ctrl-d / ctrl-u", "Page down / up"}, - {"tab", "Hide / reveal thread list"}, - {"/", "Fuzzy-search file paths and combine status:, author:, and updated:true filters"}, - {"F", "Clear the active thread filter and show every thread"}, - {"n / N", "Next / previous new update"}, - {"c", "Compose a reply to the selected thread"}, - {"R", "Resolve or unresolve the selected thread"}, - {"d", "Open pull request dashboard"}, - {"enter / za", "Fold / expand thread"}, - {"b / esc", backAction}, - {"r", "Refresh now"}, + {combinedKeyLabel(m.keybindings.Navigation.Left, m.keybindings.Navigation.Right), "Focus thread list / detail"}, + {combinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up), "Move or scroll focused pane"}, + {combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "First / last item"}, + {combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"}, + {keyLabel(m.keybindings.Views.ToggleList), "Hide / reveal thread list"}, + {keyLabel(m.keybindings.Threads.Search), "Fuzzy-search file paths and combine status:, author:, and updated:true filters"}, + {keyLabel(m.keybindings.Threads.ClearFilter), "Clear the active thread filter and show every thread"}, + {combinedKeyLabel(m.keybindings.Threads.NextUnread, m.keybindings.Threads.PreviousUnread), "Next / previous new update"}, + {keyLabel(m.keybindings.Threads.Reply), "Compose a reply to the selected thread"}, + {keyLabel(m.keybindings.Threads.Resolve), "Resolve or unresolve the selected thread"}, + {keyLabel(m.keybindings.Views.Dashboard), "Open pull request dashboard"}, + {combinedKeyLabel( + m.keybindings.Threads.Toggle, + []string{sequenceKeyLabel(m.keybindings.Threads.FoldPrefix, m.keybindings.Threads.FoldToggle)}, + ), "Fold / expand thread"}, + {keyLabel(m.keybindings.General.Back), backAction}, + {keyLabel(m.keybindings.General.Refresh), "Refresh now"}, } bindings = append(bindings, - helpBinding{"?", "Close this help"}, - helpBinding{"q / ctrl-c", "Quit"}, + helpBinding{keyLabel(m.keybindings.General.Help), "Close this help"}, + helpBinding{keyLabel(m.keybindings.General.Quit), "Quit"}, ) return bindings } func (m App) helpVisibleRows() int { - return max(1, m.height-5) + // Reserve rows for the popup border, title, title divider, and footer. + return max(1, m.height-6) } func (m App) helpMaxScroll() int { @@ -1401,25 +1514,45 @@ func (m App) viewHelp() string { end := min(len(rows), start+visibleRows) title := "Pull request picker keys" - if m.screen == dashboardScreen { + if m.writeMode == writePREdit || m.writeMode == writePREditConfirm { + title = "Pull request editor keys" + } else if m.screen == dashboardScreen { title = "Pull request dashboard keys" } else if m.screen == threadScreen { title = "Review thread keys" } - lines := []string{titleStyle.Render(title)} - lines = append(lines, rows[start:end]...) + contentLines := append([]string(nil), rows[start:end]...) if len(rows) > visibleRows { - lines = append(lines, dimStyle.Render(fmt.Sprintf( - "%d–%d of %d • j/k scroll • ?/esc/q close", + contentLines = append(contentLines, dimStyle.Render(fmt.Sprintf( + "%d–%d of %d • %s/%s scroll • %s close", start+1, end, len(rows), + primaryKeyLabel(m.keybindings.Navigation.Down), + primaryKeyLabel(m.keybindings.Navigation.Up), + primaryKeyLabel(m.keybindings.General.Help)+"/"+ + primaryKeyLabel(m.keybindings.General.Back), ))) } else { - lines = append(lines, dimStyle.Render("?/esc/q close")) + contentLines = append(contentLines, dimStyle.Render( + primaryKeyLabel(m.keybindings.General.Help)+"/"+ + primaryKeyLabel(m.keybindings.General.Back)+" close", + )) } - for i := range lines { - lines[i] = ansi.Truncate(lines[i], contentWidth, "") + for i := range contentLines { + contentLines[i] = ansi.Truncate(contentLines[i], contentWidth, "") } - popup := paneStyle(true).Width(contentWidth).Render(strings.Join(lines, "\n")) + border := lipgloss.NewStyle().Foreground(paneActiveColor) + boxLines := []string{ + border.Render("╭" + strings.Repeat("─", contentWidth) + "╮"), + border.Render("│") + pad(titleStyle.Render(title), contentWidth) + border.Render("│"), + border.Render("├" + strings.Repeat("─", contentWidth) + "┤"), + } + for _, line := range contentLines { + boxLines = append(boxLines, + border.Render("│")+pad(line, contentWidth)+border.Render("│"), + ) + } + boxLines = append(boxLines, border.Render("╰"+strings.Repeat("─", contentWidth)+"╯")) + popup := strings.Join(boxLines, "\n") return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup) } @@ -1431,16 +1564,34 @@ func (m App) helpRows(contentWidth int) []string { keyWidth := min(17, max(8, contentWidth/3)) actionWidth := max(1, contentWidth-keyWidth-1) var rows []string - for _, binding := range m.helpBindings() { - wrapped := ansi.Hardwrap(ansi.Wordwrap(binding.action, actionWidth, ""), actionWidth, false) - actionLines := strings.Split(wrapped, "\n") - for index, action := range actionLines { - key := "" - if index == 0 { - key = binding.key + bindings := m.helpBindings() + for bindingIndex, binding := range bindings { + wrappedKeys := ansi.Hardwrap( + ansi.Wordwrap(binding.key, keyWidth, ""), + keyWidth, + false, + ) + keyLines := strings.Split(wrappedKeys, "\n") + wrappedAction := ansi.Hardwrap( + ansi.Wordwrap(binding.action, actionWidth, ""), + actionWidth, + false, + ) + actionLines := strings.Split(wrappedAction, "\n") + lineCount := max(len(keyLines), len(actionLines)) + for lineIndex := range lineCount { + key, action := "", "" + if lineIndex < len(keyLines) { + key = keyLines[lineIndex] + } + if lineIndex < len(actionLines) { + action = actionLines[lineIndex] } rows = append(rows, titleStyle.Render(pad(key, keyWidth))+" "+action) } + if bindingIndex < len(bindings)-1 { + rows = append(rows, dimStyle.Render(strings.Repeat("─", contentWidth))) + } } return rows } @@ -1512,9 +1663,22 @@ func (m App) viewPRs() string { } lines = append(lines, line) } - footer := "? keys • j/k move • enter dashboard • q quit" + footer := fmt.Sprintf( + "%s keys • %s move • %s dashboard • %s quit", + primaryKeyLabel(m.keybindings.General.Help), + primaryCombinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up), + primaryKeyLabel(m.keybindings.Views.Open), + primaryKeyLabel(m.keybindings.General.Quit), + ) if m.dashboardMode == "hotkey" { - footer = "? keys • j/k move • enter threads • d dashboard • q quit" + footer = fmt.Sprintf( + "%s keys • %s move • %s threads • %s dashboard • %s quit", + primaryKeyLabel(m.keybindings.General.Help), + primaryCombinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up), + primaryKeyLabel(m.keybindings.Views.Open), + primaryKeyLabel(m.keybindings.Views.Dashboard), + primaryKeyLabel(m.keybindings.General.Quit), + ) } return m.frame(lines, footer) } @@ -1552,9 +1716,22 @@ func (m App) viewDashboard() string { maxScroll := max(0, len(lines)-viewportHeight) scroll := min(m.scroll, maxScroll) visible := lines[scroll:min(len(lines), scroll+viewportHeight)] - footer := "? keys • j/k scroll • enter threads • b back • q quit" + footer := fmt.Sprintf( + "%s keys • %s scroll • %s threads • %s back • %s quit", + primaryKeyLabel(m.keybindings.General.Help), + primaryCombinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up), + primaryKeyLabel(m.keybindings.Views.Open), + primaryKeyLabel(m.keybindings.General.Back), + primaryKeyLabel(m.keybindings.General.Quit), + ) if m.writeMode == writePREdit { - footer = "tab fields • ctrl-d/u page • v/V select • y copy • p paste • ctrl-s review • esc normal/cancel" + footer = fmt.Sprintf( + "%s keys • %s fields • %s review • %s normal/cancel", + primaryKeyLabel(m.keybindings.General.Help), + primaryCombinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.PreviousField), + primaryKeyLabel(m.keybindings.Input.Submit), + primaryKeyLabel(m.keybindings.Input.Cancel), + ) m.positionPREditHardwareCursor(scroll, viewportHeight) } view := m.frame(visible, footer) @@ -2010,11 +2187,30 @@ func (m App) viewThreads() string { right := m.threadDetail(rightWidth, contentHeight) body = lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) } - help := "? keys • h/l focus • j/k move/scroll • c reply • R resolve • d dashboard • b back • q quit" + help := fmt.Sprintf( + "%s keys • %s focus • %s move/scroll • %s reply • %s resolve • %s dashboard • %s back • %s quit", + primaryKeyLabel(m.keybindings.General.Help), + primaryCombinedKeyLabel(m.keybindings.Navigation.Left, m.keybindings.Navigation.Right), + primaryCombinedKeyLabel(m.keybindings.Navigation.Down, m.keybindings.Navigation.Up), + primaryKeyLabel(m.keybindings.Threads.Reply), + primaryKeyLabel(m.keybindings.Threads.Resolve), + primaryKeyLabel(m.keybindings.Views.Dashboard), + primaryKeyLabel(m.keybindings.General.Back), + primaryKeyLabel(m.keybindings.General.Quit), + ) if m.searching { - help = "path words • status:open • author:name • updated:true • enter apply • esc cancel" + help = fmt.Sprintf( + "path words • status:open • author:name • updated:true • %s apply • %s cancel", + primaryKeyLabel(m.keybindings.Input.Newline), + primaryKeyLabel(m.keybindings.Input.Cancel), + ) } else if m.writeMode == writeReply { - help = "reply inline • enter newline • ctrl-s review • esc cancel" + help = fmt.Sprintf( + "reply inline • %s newline • %s review • %s cancel", + primaryKeyLabel(m.keybindings.Input.Newline), + primaryKeyLabel(m.keybindings.Input.Submit), + primaryKeyLabel(m.keybindings.Input.Cancel), + ) } return m.frame(append(top, body), help) } @@ -2138,7 +2334,16 @@ func (m App) detailLines(width int) []detailLine { {anchor: "header", text: titleStyle.Render(fmt.Sprintf("[%d/%d] %s:%d", m.threadIndex+1, len(m.details.Threads), truncatePath(thread.Path, max(8, width-24)), thread.Line)) + " " + dimStyle.Render(status)}, } if m.folded[thread.ID] { - lines = append(lines, detailLine{}, detailLine{text: dimStyle.Render("Thread folded. Press za or enter to expand.")}) + lines = append(lines, detailLine{}, detailLine{text: dimStyle.Render(fmt.Sprintf( + "Thread folded. Press %s to expand.", + primaryCombinedKeyLabel( + m.keybindings.Threads.Toggle, + []string{primarySequenceKeyLabel( + m.keybindings.Threads.FoldPrefix, + m.keybindings.Threads.FoldToggle, + )}, + ), + ))}) } else { if len(thread.Comments) > 0 { lines = append(lines, detailLine{}) @@ -2294,7 +2499,12 @@ func (m App) inlineReplyLines(width int) []detailLine { } lines = append(lines, detailLine{ rail: rail, - text: dimStyle.Render("enter newline • ctrl-s review • esc cancel"), + text: dimStyle.Render(fmt.Sprintf( + "%s newline • %s review • %s cancel", + primaryKeyLabel(m.keybindings.Input.Newline), + primaryKeyLabel(m.keybindings.Input.Submit), + primaryKeyLabel(m.keybindings.Input.Cancel), + )), }) return lines } diff --git a/tui_test.go b/tui_test.go index 6079f8d..6a372bf 100644 --- a/tui_test.go +++ b/tui_test.go @@ -687,6 +687,82 @@ func TestDashboardDescriptionCanUseStandardEditingMode(t *testing.T) { } } +func TestDashboardEditorHelpIsPopupOnlyAndAvailableFromNormalMode(t *testing.T) { + m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) + m.screen, m.loading, m.width, m.height = dashboardScreen, false, 70, 24 + m.details = PRDetails{ + PullRequest: PullRequest{ + ID: "pr", Owner: "o", Repository: "r", RepoWithOwner: "o/r", + Number: 1, Title: "Title", + }, + Body: "description", BaseRef: "main", + Permissions: ViewerPermissions{CanUpdatePR: true}, + } + m.startPREdit() + + editorContent := ansi.Strip(strings.Join(m.dashboardEditLines(), "\n")) + if strings.Contains(editorContent, "normal/cancel") || + strings.Contains(editorContent, "copy") || + strings.Contains(editorContent, "paste") { + t.Fatalf("key guidance leaked into scrollable editor content:\n%s", editorContent) + } + + updated, _ := m.Update(runeKey("?")) + m = updated.(App) + if !m.helpVisible { + t.Fatal("? did not open help from Vim Normal mode") + } + help := ansi.Strip(m.View()) + allHelpRows := ansi.Strip(strings.Join(m.helpRows(m.helpContentWidth()), "\n")) + if !strings.Contains(help, "Pull request editor keys") || + !strings.Contains(allHelpRows, "system clipboard") { + t.Fatalf("editor help is missing contextual bindings:\n%s", help) + } + editorBindings := m.helpBindings() + if len(editorBindings) < 3 || editorBindings[2].key != "esc" { + t.Fatalf("editor cancel binding = %#v, want esc", editorBindings) + } + + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = updated.(App) + if m.helpVisible || m.writeMode != writePREdit { + t.Fatalf("closing help left visible=%v writeMode=%d", m.helpVisible, m.writeMode) + } +} + +func TestDashboardEditorQuestionMarkRemainsTextInInsertModeAndF1OpensHelp(t *testing.T) { + m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) + m.screen, m.loading, m.width, m.height = dashboardScreen, false, 70, 24 + m.details = PRDetails{ + PullRequest: PullRequest{ + ID: "pr", Owner: "o", Repository: "r", RepoWithOwner: "o/r", + Number: 1, Title: "Title", + }, + Body: "description", BaseRef: "main", + Permissions: ViewerPermissions{CanUpdatePR: true}, + } + m.startPREdit() + updated, _ := m.updatePREditInput(runeKey("i")) + m = updated.(App) + + before := m.prEditEditors[prEditBodyField].Text + updated, _ = m.Update(runeKey("?")) + m = updated.(App) + if m.helpVisible || m.prEditEditors[prEditBodyField].Text == before { + t.Fatalf( + "insert-mode ? help=%v text=%q", + m.helpVisible, + m.prEditEditors[prEditBodyField].Text, + ) + } + + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyF1}) + m = updated.(App) + if !m.helpVisible { + t.Fatal("f1 did not open editor help from Insert mode") + } +} + func TestDashboardVimEscapeReturnsToNormalBeforeClosingEditor(t *testing.T) { m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30 @@ -930,7 +1006,7 @@ func TestReplyComposerRendersInlineWithCurrentThread(t *testing.T) { updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}) m = updated.(App) plain := ansi.Strip(m.View()) - for _, wanted := range []string{"Existing review context", "Reply draft", "ctrl-s review"} { + for _, wanted := range []string{"Existing review context", "Reply draft", "ctrl+s review"} { if !strings.Contains(plain, wanted) { t.Fatalf("inline reply view is missing %q:\n%s", wanted, plain) } @@ -1044,6 +1120,32 @@ func TestContextualHelpOpensAndClosesWithoutLeavingScreen(t *testing.T) { } } +func TestConfiguredNavigationReplacesDefaultsAcrossScreensAndHelp(t *testing.T) { + settings := defaultAppSettings() + settings.KeyBindings.Navigation.Down = []string{"ctrl+j"} + settings.KeyBindings.Navigation.Up = []string{"ctrl+k"} + m := NewAppWithSettings(nil, "o", "r", false, 50, time.Second, settings) + m.screen, m.loading, m.width, m.height = prScreen, false, 80, 24 + m.prs = []PullRequest{{ID: "1"}, {ID: "2"}} + + updated, _ := m.Update(runeKey("j")) + m = updated.(App) + if m.prIndex != 0 { + t.Fatalf("removed default j still moved selection: %d", m.prIndex) + } + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlJ}) + m = updated.(App) + if m.prIndex != 1 { + t.Fatalf("configured ctrl+j did not move selection: %d", m.prIndex) + } + + m.helpVisible = true + plain := ansi.Strip(m.viewHelp()) + if !strings.Contains(plain, "ctrl+j") || strings.Contains(plain, "j / down") { + t.Fatalf("help did not use configured navigation:\n%s", plain) + } +} + func TestHelpCanScrollInShortTerminal(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen, m.helpVisible = threadScreen, true @@ -1085,6 +1187,126 @@ func TestHelpWrapsLongActionsWithoutCapabilityStatus(t *testing.T) { } } +func TestHelpWrapsLongKeyGroupsAndSeparatesBindings(t *testing.T) { + m := NewApp(nil, "o", "r", false, 50, 10*time.Second) + m.screen, m.width, m.height = threadScreen, 46, 30 + rows := m.helpRows(m.helpContentWidth()) + plain := ansi.Strip(strings.Join(rows, "\n")) + + for _, key := range []string{"h", "left", "l", "right"} { + if !strings.Contains(plain, key) { + t.Fatalf("wrapped focus binding lost %q:\n%s", key, plain) + } + } + if !strings.Contains(plain, strings.Repeat("─", m.helpContentWidth())) { + t.Fatalf("help bindings are not visually separated:\n%s", plain) + } + for index, row := range rows { + if width := ansi.StringWidth(row); width > m.helpContentWidth() { + t.Fatalf( + "wrapped help row %d width = %d, content width = %d", + index, width, m.helpContentWidth(), + ) + } + } +} + +func TestHelpTitleIsSeparatedFromFirstBinding(t *testing.T) { + m := NewApp(nil, "o", "r", false, 50, 10*time.Second) + m.screen, m.helpVisible = threadScreen, true + m.width, m.height = 70, 24 + + lines := strings.Split(ansi.Strip(m.viewHelp()), "\n") + titleLine := -1 + for index, line := range lines { + if strings.Contains(line, "Review thread keys") { + titleLine = index + break + } + } + if titleLine < 0 || titleLine+2 >= len(lines) { + t.Fatalf("help title was not rendered:\n%s", strings.Join(lines, "\n")) + } + if !strings.Contains(lines[titleLine+1], "├") || + !strings.Contains(lines[titleLine+1], "┤") { + t.Fatalf("help title divider is not joined to the box:\n%s", strings.Join(lines, "\n")) + } + if strings.Contains(lines[titleLine+1], "Focus thread") { + t.Fatalf("first binding shares the title divider row:\n%s", strings.Join(lines, "\n")) + } +} + +func TestHelpFooterUsesOnlyPrimaryConfiguredKeys(t *testing.T) { + settings := defaultAppSettings() + settings.KeyBindings.Navigation.Down = []string{"j", "down", "ctrl+j"} + settings.KeyBindings.Navigation.Up = []string{"k", "up", "ctrl+k"} + settings.KeyBindings.General.Help = []string{"?", "f1"} + settings.KeyBindings.General.Back = []string{"b", "esc"} + m := NewAppWithSettings(nil, "o", "r", false, 50, time.Second, settings) + m.screen, m.helpVisible = threadScreen, true + m.width, m.height = 70, 9 + + lines := strings.Split(ansi.Strip(m.viewHelp()), "\n") + footer := "" + for _, line := range lines { + if strings.Contains(line, "scroll") && strings.Contains(line, "close") { + footer = line + break + } + } + if footer == "" { + t.Fatalf("help footer was not found:\n%s", strings.Join(lines, "\n")) + } + if !strings.Contains(footer, "j/k scroll") || + !strings.Contains(footer, "?/b close") { + t.Fatalf("help footer does not show primary keys:\n%s", footer) + } + for _, secondary := range []string{"down", "up", "ctrl+j", "ctrl+k", "f1", "esc"} { + if strings.Contains(footer, secondary) { + t.Fatalf("help footer contains secondary key %q:\n%s", secondary, footer) + } + } +} + +func TestEveryCompactScreenFooterUsesOnlyPrimaryKeys(t *testing.T) { + m := NewApp(nil, "o", "r", false, 50, time.Second) + m.width, m.height, m.loading = 100, 24, false + + m.screen = prScreen + prFooter := compactFooterLine(ansi.Strip(m.viewPRs())) + if !strings.Contains(prFooter, "j / k move") || + strings.Contains(prFooter, "down") || strings.Contains(prFooter, "up") { + t.Fatalf("picker footer is not compact:\n%s", prFooter) + } + + m.screen = dashboardScreen + m.details = PRDetails{PullRequest: PullRequest{ + Owner: "o", Repository: "r", RepoWithOwner: "o/r", Number: 1, Title: "PR", + }} + dashboardFooter := compactFooterLine(ansi.Strip(m.viewDashboard())) + if !strings.Contains(dashboardFooter, "j / k scroll") || + strings.Contains(dashboardFooter, "down") || strings.Contains(dashboardFooter, "up") { + t.Fatalf("dashboard footer is not compact:\n%s", dashboardFooter) + } + + m.screen = threadScreen + threadFooter := compactFooterLine(ansi.Strip(m.viewThreads())) + if !strings.Contains(threadFooter, "h / l focus") || + !strings.Contains(threadFooter, "j / k move/scroll") || + strings.Contains(threadFooter, "left") || strings.Contains(threadFooter, "right") { + t.Fatalf("thread footer is not compact:\n%s", threadFooter) + } +} + +func compactFooterLine(view string) string { + for _, line := range strings.Split(view, "\n") { + if strings.Contains(line, " keys ") { + return strings.TrimSpace(line) + } + } + return "" +} + func TestUppercaseFClearsAppliedThreadFilter(t *testing.T) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen = threadScreen