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"` Health []string `toml:"health"` Edit []string `toml:"edit"` AutoMerge []string `toml:"auto_merge"` MergeNow []string `toml:"merge_now"` ToggleList []string `toml:"toggle_list"` AI []string `toml:"ai"` } type ThreadKeyBindings struct { Search []string `toml:"search"` ClearFilter []string `toml:"clear_filter"` NextUnread []string `toml:"next_unread"` PreviousUnread []string `toml:"previous_unread"` MarkRead []string `toml:"mark_read"` 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"}, Health: []string{"H"}, Edit: []string{"e"}, ToggleList: []string{"tab"}, AutoMerge: []string{"a"}, MergeNow: []string{"M"}, AI: []string{"A"}, }, Threads: ThreadKeyBindings{ Search: []string{"/"}, ClearFilter: []string{"F"}, NextUnread: []string{"n"}, PreviousUnread: []string{"N"}, MarkRead: []string{"m"}, 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 == dashboardScreen { switch { case keyMatches(key, k.Views.AutoMerge): return "a" case keyMatches(key, k.Views.MergeNow): return "M" } } 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.MarkRead): return "m" 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.Health): return "H" case keyMatches(key, k.Views.Edit): return "e" case (current == dashboardScreen || current == threadScreen) && keyMatches(key, k.Views.AI): return "A" 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" case keyMatches(key, k.Navigation.Down): return "down" case keyMatches(key, k.Navigation.Up): return "up" case keyMatches(key, k.Navigation.PageDown): return "ctrl+d" case keyMatches(key, k.Navigation.PageUp): return "ctrl+u" case keyMatches(key, k.Navigation.First): return "g" case keyMatches(key, k.Navigation.Last): return "G" } } 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, "health": bindings.Views.Health, "edit": bindings.Views.Edit, "auto_merge": bindings.Views.AutoMerge, "merge_now": bindings.Views.MergeNow, "toggle_list": bindings.Views.ToggleList, "ai": bindings.Views.AI, }}, {"keybindings.threads", map[string][]string{ "search": bindings.Threads.Search, "clear_filter": bindings.Threads.ClearFilter, "next_unread": bindings.Threads.NextUnread, "previous_unread": bindings.Threads.PreviousUnread, "mark_read": bindings.Threads.MarkRead, "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}, {"health", views.Health}, } 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}, contextBinding{"auto_merge", views.AutoMerge}, contextBinding{"merge_now", views.MergeNow}, contextBinding{"ai", views.AI}, )...); err != nil { return err } if err := validateKeyContext("health screen", screenCommon...); 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{"mark_read", threads.MarkRead}, contextBinding{"reply", threads.Reply}, contextBinding{"resolve", threads.Resolve}, contextBinding{"toggle", threads.Toggle}, contextBinding{"fold_prefix", threads.FoldPrefix}, contextBinding{"ai", views.AI}, )...); 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{"delete_forward", input.DeleteForward}, contextBinding{"clear", input.Clear}, contextBinding{"line_start", input.LineStart}, contextBinding{"line_end", input.LineEnd}, contextBinding{"left", nonTextBindings(navigation.Left)}, contextBinding{"right", nonTextBindings(navigation.Right)}, ); 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}, contextBinding{"delete_forward", input.DeleteForward}, contextBinding{"line_start", input.LineStart}, contextBinding{"line_end", input.LineEnd}, contextBinding{"left", nonTextBindings(navigation.Left)}, contextBinding{"down", nonTextBindings(navigation.Down)}, contextBinding{"up", nonTextBindings(navigation.Up)}, contextBinding{"right", nonTextBindings(navigation.Right)}, ); 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 } if err := validateKeyContext("AI menu", contextBinding{"quit", nonTextBindings(general.Quit)}, contextBinding{"cancel", appendCopy(general.Back, input.Cancel...)}, contextBinding{"down", navigation.Down}, contextBinding{"up", navigation.Up}, contextBinding{"select", appendCopy(views.Open, input.Newline...)}, ); err != nil { return err } if err := validateKeyContext("AI confirmation", contextBinding{"quit", nonTextBindings(general.Quit)}, contextBinding{"confirm", general.Confirm}, contextBinding{"cancel", appendCopy(general.Reject, input.Cancel...)}, contextBinding{"down", navigation.Down}, contextBinding{"up", navigation.Up}, contextBinding{"page_down", navigation.PageDown}, contextBinding{"page_up", navigation.PageUp}, ); 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{"substitute", vim.ReplaceCharacter}, 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{"left", nonTextBindings(navigation.Left)}, contextBinding{"right", nonTextBindings(navigation.Right)}, 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...) }