From 8d20e9218ece5927d786d6e2fac5c50572fb9c81 Mon Sep 17 00:00:00 2001 From: Drew DeVault Date: Fri, 15 Mar 2019 01:12:06 -0400 Subject: [PATCH] Implement key bindings subsystem Which is not yet rigged up --- config/aerc.conf | 55 ++++---- config/bindings.go | 284 ++++++++++++++++++++++++++++++++++++++++ config/bindings_test.go | 61 +++++++++ config/config.go | 16 ++- go.mod | 3 +- go.sum | 9 ++ widgets/exline.go | 6 +- 7 files changed, 400 insertions(+), 34 deletions(-) create mode 100644 config/bindings.go create mode 100644 config/bindings_test.go diff --git a/config/aerc.conf b/config/aerc.conf index 76b0310..3b29a77 100644 --- a/config/aerc.conf +++ b/config/aerc.conf @@ -91,43 +91,44 @@ alternatives=text/plain,text/html [lbinds] # -# Binds are of the form = -# To use '=' in a key sequence, substitute it with "Eq": "" -# If you wish to bind #, you can wrap the key sequence in quotes: "#" = quit +# Binds are of the form = +# Pressing in sequence will then simulate pressing # -# lbinds are bindings that take effect in the list view -# mbinds are bindings that take effect in the message view -q=:quit -=:quit +# Use to refer to control+something. +# +# lbinds are effective in the list view +# mbinds are effective in the message view +q = :quit + = :quit -j=:next-message -=:next-message -=:next-message --scroll 50% -=:next-message --scroll 100% -=:next-message --scroll 100% -=:next-message --scroll 1 +j = :next-message + = :next-message + = :next-message --scroll 50% + = :next-message --scroll 100% + = :next-message --scroll 100% + = :next-message --scroll 1 -k=:previous-message -=:previous-message -=:previous-message --scroll 50% -=:previous-message --scroll 100% -=:previous-message --scroll 100% -=:previous-message --scroll 1 -g=:select-message 0 -G=:select-message -1 +k = :previous-message + = :previous-message + = :previous-message --scroll 50% + = :previous-message --scroll 100% + = :previous-message --scroll 100% + = :previous-message --scroll 1 +g = :select-message 0 +G = :select-message -1 -J=:next-folder -K=:previous-folder +J = :next-folder +K = :previous-folder l = :next-account = :next-account h = :previous-account = :previous-account -=:view-message -d=:confirm 'Really delete this message?' ':delete-message' + = :view-message +d = :confirm 'Really delete this message?' ':delete-message' -c=:cd -$=:term-exec +c = :cd +$ = :term-exec [mbinds] # diff --git a/config/bindings.go b/config/bindings.go new file mode 100644 index 0000000..c10b68f --- /dev/null +++ b/config/bindings.go @@ -0,0 +1,284 @@ +package config + +import ( + "bytes" + "errors" + "fmt" + "io" + "strings" + + "github.com/gdamore/tcell" +) + +type KeyStroke struct { + Key tcell.Key + Rune rune +} + +type Binding struct { + Output []KeyStroke + Input []KeyStroke +} + +type KeyBindings []*Binding + +const ( + BINDING_FOUND = iota + BINDING_INCOMPLETE + BINDING_NOT_FOUND +) + +type BindingSearchResult int + +func NewKeyBindings() *KeyBindings { + return &KeyBindings{} +} + +func (bindings *KeyBindings) Add(binding *Binding) { + // TODO: Search for conflicts? + *bindings = append(*bindings, binding) +} + +func (bindings *KeyBindings) GetBinding( + input []KeyStroke) (BindingSearchResult, []KeyStroke) { + + incomplete := false + // TODO: This could probably be a sorted list to speed things up + // TODO: Deal with bindings that share a prefix + for _, binding := range *bindings { + if len(binding.Input) < len(input) { + continue + } + for i, stroke := range input { + if stroke != binding.Input[i] { + goto next + } + } + if len(binding.Input) != len(input) { + incomplete = true + } else { + return BINDING_FOUND, binding.Output + } + next: + } + if incomplete { + return BINDING_INCOMPLETE, nil + } + return BINDING_NOT_FOUND, nil +} + +var ( + keyNames map[string]tcell.Key +) + +func ParseKeyStrokes(keystrokes string) ([]KeyStroke, error) { + var strokes []KeyStroke + buf := bytes.NewBufferString(keystrokes) + for { + tok, _, err := buf.ReadRune() + if err == io.EOF { + break + } else if err != nil { + return nil, err + } + // TODO: make it possible to bind to < or > themselves (and default to + // switching accounts) + switch tok { + case '<': + name, err := buf.ReadString(byte('>')) + if err == io.EOF { + return nil, errors.New("Expecting '>'") + } else if err != nil { + return nil, err + } else if name == ">" { + return nil, errors.New("Expected a key name") + } + name = name[:len(name)-1] + if key, ok := keyNames[strings.ToLower(name)]; ok { + strokes = append(strokes, KeyStroke{ + Key: key, + }) + } else { + return nil, errors.New(fmt.Sprintf("Unknown key '%s'", name)) + } + case '>': + return nil, errors.New("Found '>' without '<'") + default: + strokes = append(strokes, KeyStroke{ + Key: tcell.KeyRune, + Rune: tok, + }) + } + } + return strokes, nil +} + +func ParseBinding(input, output string) (*Binding, error) { + in, err := ParseKeyStrokes(input) + if err != nil { + return nil, err + } + out, err := ParseKeyStrokes(output) + if err != nil { + return nil, err + } + return &Binding{ + Input: in, + Output: out, + }, nil +} + +func init() { + keyNames = make(map[string]tcell.Key) + keyNames["up"] = tcell.KeyUp + keyNames["down"] = tcell.KeyDown + keyNames["right"] = tcell.KeyRight + keyNames["left"] = tcell.KeyLeft + keyNames["upleft"] = tcell.KeyUpLeft + keyNames["upright"] = tcell.KeyUpRight + keyNames["downleft"] = tcell.KeyDownLeft + keyNames["downright"] = tcell.KeyDownRight + keyNames["center"] = tcell.KeyCenter + keyNames["pgup"] = tcell.KeyPgUp + keyNames["pgdn"] = tcell.KeyPgDn + keyNames["home"] = tcell.KeyHome + keyNames["end"] = tcell.KeyEnd + keyNames["insert"] = tcell.KeyInsert + keyNames["delete"] = tcell.KeyDelete + keyNames["help"] = tcell.KeyHelp + keyNames["exit"] = tcell.KeyExit + keyNames["clear"] = tcell.KeyClear + keyNames["cancel"] = tcell.KeyCancel + keyNames["print"] = tcell.KeyPrint + keyNames["pause"] = tcell.KeyPause + keyNames["backtab"] = tcell.KeyBacktab + keyNames["f1"] = tcell.KeyF1 + keyNames["f2"] = tcell.KeyF2 + keyNames["f3"] = tcell.KeyF3 + keyNames["f4"] = tcell.KeyF4 + keyNames["f5"] = tcell.KeyF5 + keyNames["f6"] = tcell.KeyF6 + keyNames["f7"] = tcell.KeyF7 + keyNames["f8"] = tcell.KeyF8 + keyNames["f9"] = tcell.KeyF9 + keyNames["f10"] = tcell.KeyF10 + keyNames["f11"] = tcell.KeyF11 + keyNames["f12"] = tcell.KeyF12 + keyNames["f13"] = tcell.KeyF13 + keyNames["f14"] = tcell.KeyF14 + keyNames["f15"] = tcell.KeyF15 + keyNames["f16"] = tcell.KeyF16 + keyNames["f17"] = tcell.KeyF17 + keyNames["f18"] = tcell.KeyF18 + keyNames["f19"] = tcell.KeyF19 + keyNames["f20"] = tcell.KeyF20 + keyNames["f21"] = tcell.KeyF21 + keyNames["f22"] = tcell.KeyF22 + keyNames["f23"] = tcell.KeyF23 + keyNames["f24"] = tcell.KeyF24 + keyNames["f25"] = tcell.KeyF25 + keyNames["f26"] = tcell.KeyF26 + keyNames["f27"] = tcell.KeyF27 + keyNames["f28"] = tcell.KeyF28 + keyNames["f29"] = tcell.KeyF29 + keyNames["f30"] = tcell.KeyF30 + keyNames["f31"] = tcell.KeyF31 + keyNames["f32"] = tcell.KeyF32 + keyNames["f33"] = tcell.KeyF33 + keyNames["f34"] = tcell.KeyF34 + keyNames["f35"] = tcell.KeyF35 + keyNames["f36"] = tcell.KeyF36 + keyNames["f37"] = tcell.KeyF37 + keyNames["f38"] = tcell.KeyF38 + keyNames["f39"] = tcell.KeyF39 + keyNames["f40"] = tcell.KeyF40 + keyNames["f41"] = tcell.KeyF41 + keyNames["f42"] = tcell.KeyF42 + keyNames["f43"] = tcell.KeyF43 + keyNames["f44"] = tcell.KeyF44 + keyNames["f45"] = tcell.KeyF45 + keyNames["f46"] = tcell.KeyF46 + keyNames["f47"] = tcell.KeyF47 + keyNames["f48"] = tcell.KeyF48 + keyNames["f49"] = tcell.KeyF49 + keyNames["f50"] = tcell.KeyF50 + keyNames["f51"] = tcell.KeyF51 + keyNames["f52"] = tcell.KeyF52 + keyNames["f53"] = tcell.KeyF53 + keyNames["f54"] = tcell.KeyF54 + keyNames["f55"] = tcell.KeyF55 + keyNames["f56"] = tcell.KeyF56 + keyNames["f57"] = tcell.KeyF57 + keyNames["f58"] = tcell.KeyF58 + keyNames["f59"] = tcell.KeyF59 + keyNames["f60"] = tcell.KeyF60 + keyNames["f61"] = tcell.KeyF61 + keyNames["f62"] = tcell.KeyF62 + keyNames["f63"] = tcell.KeyF63 + keyNames["f64"] = tcell.KeyF64 + keyNames["c-space"] = tcell.KeyCtrlSpace + keyNames["c-a"] = tcell.KeyCtrlA + keyNames["c-b"] = tcell.KeyCtrlB + keyNames["c-c"] = tcell.KeyCtrlC + keyNames["c-d"] = tcell.KeyCtrlD + keyNames["c-e"] = tcell.KeyCtrlE + keyNames["c-f"] = tcell.KeyCtrlF + keyNames["c-g"] = tcell.KeyCtrlG + keyNames["c-h"] = tcell.KeyCtrlH + keyNames["c-i"] = tcell.KeyCtrlI + keyNames["c-j"] = tcell.KeyCtrlJ + keyNames["c-k"] = tcell.KeyCtrlK + keyNames["c-l"] = tcell.KeyCtrlL + keyNames["c-m"] = tcell.KeyCtrlM + keyNames["c-n"] = tcell.KeyCtrlN + keyNames["c-o"] = tcell.KeyCtrlO + keyNames["c-p"] = tcell.KeyCtrlP + keyNames["c-q"] = tcell.KeyCtrlQ + keyNames["c-r"] = tcell.KeyCtrlR + keyNames["c-s"] = tcell.KeyCtrlS + keyNames["c-t"] = tcell.KeyCtrlT + keyNames["c-u"] = tcell.KeyCtrlU + keyNames["c-v"] = tcell.KeyCtrlV + keyNames["c-w"] = tcell.KeyCtrlW + keyNames["c-x"] = tcell.KeyCtrlX + keyNames["c-y"] = tcell.KeyCtrlY + keyNames["c-z"] = tcell.KeyCtrlZ + keyNames["c-]"] = tcell.KeyCtrlLeftSq + keyNames["c-\\"] = tcell.KeyCtrlBackslash + keyNames["c-["] = tcell.KeyCtrlRightSq + keyNames["c-^"] = tcell.KeyCtrlCarat + keyNames["c-_"] = tcell.KeyCtrlUnderscore + keyNames["NUL"] = tcell.KeyNUL + keyNames["SOH"] = tcell.KeySOH + keyNames["STX"] = tcell.KeySTX + keyNames["ETX"] = tcell.KeyETX + keyNames["EOT"] = tcell.KeyEOT + keyNames["ENQ"] = tcell.KeyENQ + keyNames["ACK"] = tcell.KeyACK + keyNames["BEL"] = tcell.KeyBEL + keyNames["BS"] = tcell.KeyBS + keyNames["TAB"] = tcell.KeyTAB + keyNames["LF"] = tcell.KeyLF + keyNames["VT"] = tcell.KeyVT + keyNames["FF"] = tcell.KeyFF + keyNames["CR"] = tcell.KeyCR + keyNames["SO"] = tcell.KeySO + keyNames["SI"] = tcell.KeySI + keyNames["DLE"] = tcell.KeyDLE + keyNames["DC1"] = tcell.KeyDC1 + keyNames["DC2"] = tcell.KeyDC2 + keyNames["DC3"] = tcell.KeyDC3 + keyNames["DC4"] = tcell.KeyDC4 + keyNames["NAK"] = tcell.KeyNAK + keyNames["SYN"] = tcell.KeySYN + keyNames["ETB"] = tcell.KeyETB + keyNames["CAN"] = tcell.KeyCAN + keyNames["EM"] = tcell.KeyEM + keyNames["SUB"] = tcell.KeySUB + keyNames["ESC"] = tcell.KeyESC + keyNames["FS"] = tcell.KeyFS + keyNames["GS"] = tcell.KeyGS + keyNames["RS"] = tcell.KeyRS + keyNames["US"] = tcell.KeyUS + keyNames["DEL"] = tcell.KeyDEL +} diff --git a/config/bindings_test.go b/config/bindings_test.go new file mode 100644 index 0000000..1d1cbfe --- /dev/null +++ b/config/bindings_test.go @@ -0,0 +1,61 @@ +package config + +import ( + "fmt" + "testing" + + "github.com/gdamore/tcell" + "github.com/stretchr/testify/assert" +) + +func TestGetBinding(t *testing.T) { + assert := assert.New(t) + + bindings := NewKeyBindings() + add := func(binding, cmd string) { + b, _ := ParseBinding(binding, cmd) + bindings.Add(b) + } + + add("abc", ":abc") + add("cba", ":cba") + add("foo", ":foo") + add("bar", ":bar") + + test := func(input []KeyStroke, result int, output string) { + _output, _ := ParseKeyStrokes(output) + r, out := bindings.GetBinding(input) + assert.Equal(result, int(r), fmt.Sprintf( + "%s: Expected result %d, got %d", output, result, r)) + assert.Equal(_output, out, fmt.Sprintf( + "%s: Expected output %v, got %v", output, _output, out)) + } + + test([]KeyStroke{ + {tcell.KeyRune, 'a'}, + }, BINDING_INCOMPLETE, "") + test([]KeyStroke{ + {tcell.KeyRune, 'a'}, + {tcell.KeyRune, 'b'}, + {tcell.KeyRune, 'c'}, + }, BINDING_FOUND, ":abc") + test([]KeyStroke{ + {tcell.KeyRune, 'c'}, + {tcell.KeyRune, 'b'}, + {tcell.KeyRune, 'a'}, + }, BINDING_FOUND, ":cba") + test([]KeyStroke{ + {tcell.KeyRune, 'f'}, + {tcell.KeyRune, 'o'}, + }, BINDING_INCOMPLETE, "") + test([]KeyStroke{ + {tcell.KeyRune, '4'}, + {tcell.KeyRune, '0'}, + {tcell.KeyRune, '4'}, + }, BINDING_NOT_FOUND, "") + + add("", "c-a") + test([]KeyStroke{ + {tcell.KeyCtrlA, 0}, + }, BINDING_FOUND, "c-a") +} diff --git a/config/config.go b/config/config.go index 142a1e0..ff0e094 100644 --- a/config/config.go +++ b/config/config.go @@ -29,6 +29,7 @@ type AccountConfig struct { } type AercConfig struct { + Lbinds *KeyBindings Ini *ini.File `ini:"-"` Accounts []AccountConfig `ini:"-"` Ui UIConfig @@ -94,7 +95,9 @@ func LoadConfig(root *string) (*AercConfig, error) { } file.NameMapper = mapName config := &AercConfig{ - Ini: file, + Lbinds: NewKeyBindings(), + Ini: file, + Ui: UIConfig{ IndexFormat: "%4C %Z %D %-17.17n %s", TimestampFormat: "%F %l:%M %p", @@ -110,9 +113,18 @@ func LoadConfig(root *string) (*AercConfig, error) { EmptyMessage: "(no messages)", }, } - if ui, err := file.GetSection("ui"); err != nil { + if ui, err := file.GetSection("ui"); err == nil { ui.MapTo(config.Ui) } + if lbinds, err := file.GetSection("lbinds"); err == nil { + for key, value := range lbinds.KeysHash() { + binding, err := ParseBinding(key, value) + if err != nil { + return nil, err + } + config.Lbinds.Add(binding) + } + } accountsPath := path.Join(*root, "accounts.conf") if accounts, err := loadAccountConfig(accountsPath); err != nil { return nil, err diff --git a/go.mod b/go.mod index 05f0880..5eae665 100644 --- a/go.mod +++ b/go.mod @@ -6,12 +6,13 @@ require ( github.com/emersion/go-sasl v0.0.0-20161116183048-7e096a0a6197 // indirect github.com/gdamore/encoding v0.0.0-20151215212835-b23993cbb635 github.com/gdamore/tcell v1.0.0 - github.com/go-ini/ini v1.32.0 + github.com/go-ini/ini v1.42.0 github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf github.com/kyoh86/xdg v0.0.0-20171127140545-8db68a8ea76a github.com/lucasb-eyer/go-colorful v0.0.0-20180531031333-d9cec903b20c github.com/mattn/go-isatty v0.0.3 github.com/mattn/go-runewidth v0.0.2 github.com/nsf/termbox-go v0.0.0-20180129072728-88b7b944be8b + github.com/stretchr/testify v1.3.0 golang.org/x/text v0.3.0 ) diff --git a/go.sum b/go.sum index be1bff1..1278e7a 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emersion/go-imap v1.0.0-beta.1 h1:bTCaVlUnb5mKoW9lEukusxguSYYZPer+q0g5t+vw5X0= github.com/emersion/go-imap v1.0.0-beta.1/go.mod h1:oydmHwiyv92ZOiNfQY9BDax5heePWN8P2+W1B2T6qjc= github.com/emersion/go-imap-idle v0.0.0-20180114101550-2af93776db6b h1:q4qkNe/W10qFGD3RWd4meQTkD0+Zrz0L4ekMvlptg60= @@ -10,6 +12,8 @@ github.com/gdamore/tcell v1.0.0 h1:oaly4AkxvDT5ffKHV/n4L8iy6FxG2QkAVl0M6cjryuE= github.com/gdamore/tcell v1.0.0/go.mod h1:tqyG50u7+Ctv1w5VX67kLzKcj9YXR/JSBZQq/+mLl1A= github.com/go-ini/ini v1.32.0 h1:/MArBHSS0TFR28yPPDK1vPIjt4wUnPBfb81i6iiyKvA= github.com/go-ini/ini v1.32.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-ini/ini v1.42.0 h1:TWr1wGj35+UiWHlBA8er89seFXxzwFn11spilrrj+38= +github.com/go-ini/ini v1.42.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf h1:7+FW5aGwISbqUtkfmIpZJGRgNFg2ioYPvFaUxdqpDsg= github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf/go.mod h1:RpwtwJQFrIEPstU94h88MWPXP2ektJZ8cZ0YntAmXiE= github.com/kyoh86/xdg v0.0.0-20171127140545-8db68a8ea76a h1:vLFQnHOnCnmlySdpHAKF+mH7MhsthJgpBbfexVhHwxY= @@ -21,5 +25,10 @@ github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNx github.com/mattn/go-runewidth v0.0.2 h1:UnlwIPBGaTZfPQ6T1IGzPI0EkYAQmT9fAEJ/poFC63o= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/nsf/termbox-go v0.0.0-20180129072728-88b7b944be8b/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/widgets/exline.go b/widgets/exline.go index e0954d7..77f1414 100644 --- a/widgets/exline.go +++ b/widgets/exline.go @@ -126,10 +126,8 @@ func (ex *ExLine) Event(event tcell.Event) bool { case tcell.KeyEsc, tcell.KeyCtrlC: ex.ctx.HideCursor() ex.cancel() - default: - if event.Rune() != 0 { - ex.insert(event.Rune()) - } + case tcell.KeyRune: + ex.insert(event.Rune()) } } return true