diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 1b04642..c719ee7 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -96,7 +96,10 @@ jobs: - name: Run golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: v2.11.4 + version: v2.14.0 + # Build golangci-lint with the Go version from go.mod. Release + # binaries lag behind new Go versions and refuse to lint newer code. + install-mode: goinstall args: --timeout=3m - name: Lint summary diff --git a/doc.go b/doc.go index d6557b6..e3e1430 100644 --- a/doc.go +++ b/doc.go @@ -1,6 +1,9 @@ /* Package keyboard can be used to read key presses from the keyboard, while in a terminal application. It's crossplatform and keypresses can be combined to check for ctrl+c, alt+4, ctrl-shift, alt+ctrl+right, etc. -It can also be used to simulate (mock) keypresses for CI testing. +It can also be used to simulate (mock) keypresses for CI testing, or to read keypresses from any io.Reader. + +The terminal is switched to raw mode while Listen runs and is always restored afterwards, +even if the callback returns an error or panics. Ctrl+C is delivered to the callback as keys.CtrlC. Works nicely with https://atomicgo.dev/cursor @@ -40,23 +43,31 @@ Works nicely with https://atomicgo.dev/cursor ## Simulate Key Presses (for mocking in tests) - go func() { - keyboard.SimulateKeyPress("Hello") // Simulate key press for every letter in string - keyboard.SimulateKeyPress(keys.Enter) // Simulate key press for Enter - keyboard.SimulateKeyPress(keys.CtrlShiftRight) // Simulate key press for Ctrl+Shift+Right - keyboard.SimulateKeyPress('x') // Simulate key press for a single rune - keyboard.SimulateKeyPress('x', keys.Down, 'a') // Simulate key presses for multiple inputs + go func() { + keyboard.SimulateKeyPress("Hello") // Simulate key press for every letter in string + keyboard.SimulateKeyPress(keys.Enter) // Simulate key press for Enter + keyboard.SimulateKeyPress(keys.CtrlShiftRight) // Simulate key press for Ctrl+Shift+Right + keyboard.SimulateKeyPress('x') // Simulate key press for a single rune + keyboard.SimulateKeyPress('x', keys.Down, 'a') // Simulate key presses for multiple inputs - keyboard.SimulateKeyPress(keys.Escape) // Simulate key press for Escape, which quits the program - }() + keyboard.SimulateKeyPress(keys.Escape) // Simulate key press for Escape, which quits the program + }() - keyboard.Listen(func(key keys.Key) (stop bool, err error) { - if key.Code == keys.Escape || key.Code == keys.CtrlC { - os.Exit(0) // Exit program on Escape - } + keyboard.Listen(func(key keys.Key) (stop bool, err error) { + if key.Code == keys.Escape || key.Code == keys.CtrlC { + os.Exit(0) // Exit program on Escape + } - fmt.Println("\r" + key.String()) // Print every key press - return false, nil // Return false to continue listening - }) + fmt.Println("\r" + key.String()) // Print every key press + return false, nil // Return false to continue listening + }) + +## Read Key Presses From an io.Reader + + // Every rune and escape sequence is delivered as its own key. "\r" and "\n" are delivered as keys.Enter. + err := keyboard.ListenReader(strings.NewReader("y\r"), func(key keys.Key) (stop bool, err error) { + fmt.Println(key) // Prints "y", then "enter" + return key.Code == keys.Enter, nil + }) */ package keyboard diff --git a/examples_test.go b/examples_test.go index af1bb42..0d7a607 100644 --- a/examples_test.go +++ b/examples_test.go @@ -3,6 +3,7 @@ package keyboard import ( "fmt" "os" + "strings" "atomicgo.dev/keyboard/keys" ) @@ -65,3 +66,19 @@ func ExampleSimulateKeyPress() { return false, nil // Return false to continue listening }) } + +func ExampleListenReader() { + input := strings.NewReader("hi\x1b[A\r") + + _ = ListenReader(input, func(key keys.Key) (stop bool, err error) { + fmt.Println(key) + + return key.Code == keys.Enter, nil + }) + + // Output: + // h + // i + // up + // enter +} diff --git a/go.mod b/go.mod index f57c9f6..ad49a00 100644 --- a/go.mod +++ b/go.mod @@ -1,27 +1,8 @@ module atomicgo.dev/keyboard -go 1.25.0 +go 1.27.0 require ( - github.com/MarvinJWendt/testza v0.5.2 - github.com/containerd/console v1.0.5 -) - -require ( - atomicgo.dev/assert v0.0.2 // indirect - atomicgo.dev/cursor v0.2.0 // indirect - atomicgo.dev/schedule v0.1.0 // indirect - github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gookit/color v1.6.0 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect - github.com/lithammer/fuzzysearch v1.1.8 // indirect - github.com/mattn/go-runewidth v0.0.23 // indirect - github.com/pterm/pterm v0.12.83 // indirect - github.com/sergi/go-diff v1.4.0 // indirect - github.com/stretchr/testify v1.8.0 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/term v0.42.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/sys v0.49.0 + golang.org/x/term v0.47.0 ) diff --git a/go.sum b/go.sum index 1d787e9..31af47c 100644 --- a/go.sum +++ b/go.sum @@ -1,89 +1,4 @@ -atomicgo.dev/assert v0.0.2 h1:FiKeMiZSgRrZsPo9qn/7vmr7mCsh5SZyXY4YGYiYwrg= -atomicgo.dev/assert v0.0.2/go.mod h1:ut4NcI3QDdJtlmAxQULOmA13Gz6e2DWbSAS8RUOmNYQ= -atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw= -atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU= -atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs= -atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU= -github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= -github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= -github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= -github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc= -github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0= -github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E= -github.com/gookit/color v1.6.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA= -github.com/gookit/color v1.6.0/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= -github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= -github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= -github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -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/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA= -github.com/pterm/pterm v0.12.83/go.mod h1:xlgc6bFWyJIMtmLJvGim+L7jhSReilOlOnodeIYe4Tk= -github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= -github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= diff --git a/input.go b/input.go index 012f620..94f4d87 100644 --- a/input.go +++ b/input.go @@ -1,9 +1,7 @@ package keyboard import ( - "errors" - "fmt" - "os" + "encoding/hex" "unicode/utf8" "atomicgo.dev/keyboard/internal" @@ -167,74 +165,143 @@ var hexCodes = map[string]keys.Key{ "1b5b48": {Code: keys.Home, AltPressed: false}, } -func getKeyPress() (keys.Key, error) { - var buf [256]byte +// escapeSequences holds every multi-byte input sequence that maps to a single key, +// keyed by its raw bytes. It merges sequences and hexCodes. +var escapeSequences, escapePrefixes, maxSequenceLen = buildSequenceTables() - // Read - numBytes, err := inputTTY.Read(buf[:]) - if err != nil { - if errors.Is(err, os.ErrClosed) { - return keys.Key{}, nil - } +func buildSequenceTables() (map[string]keys.Key, map[string]bool, int) { + all := make(map[string]keys.Key, len(sequences)+len(hexCodes)) + for seq, key := range sequences { + all[seq] = key + } - if err.Error() == "EOF" { - return keys.Key{}, nil - } else if err.Error() == "invalid argument" { - return keys.Key{}, nil + for code, key := range hexCodes { + raw, err := hex.DecodeString(code) + if err != nil { + panic("keyboard: invalid hex code " + code) } - return keys.Key{}, nil + all[string(raw)] = key } - // Check if it's a sequence - if k, ok := sequences[string(buf[:numBytes])]; ok { - return k, nil - } + prefixes := make(map[string]bool) + longest := 0 + + for seq := range all { + longest = max(longest, len(seq)) - hex := fmt.Sprintf("%x", buf[:numBytes]) - if k, ok := hexCodes[hex]; ok { - return k, nil + for i := 1; i < len(seq); i++ { + prefixes[seq[:i]] = true + } } - // Check if the alt key is pressed. - if numBytes > 1 && buf[0] == 0x1b { - // Remove the initial escape sequence - c, _ := utf8.DecodeRune(buf[1:]) - if c == utf8.RuneError { - return keys.Key{}, fmt.Errorf("could not decode rune after removing initial escape sequence") + return all, prefixes, longest +} + +// parseKeys splits raw input into key presses. +// +// Escape sequences and control characters always become keys of their own. If +// groupRunes is true, consecutive printable runes are combined into a single +// RuneKey, which is how pasted text arrives from a terminal. Otherwise every rune +// becomes its own key. +// +// If atEOF is false and the input ends in the middle of an escape sequence or a +// UTF-8 encoded rune, parsing stops before it. The returned count tells how many +// bytes were consumed; the rest has to be passed again together with more input. +// Bytes that are not valid UTF-8 are skipped. +func parseKeys(input []byte, atEOF, groupRunes bool) ([]keys.Key, int) { + var ( + parsed []keys.Key + group []rune + pos int + ) + + flush := func() { + switch { + case len(group) == 0: + return + case len(group) == 1 && group[0] == ' ': + parsed = append(parsed, keys.Key{Code: keys.Space, Runes: group}) + default: + parsed = append(parsed, keys.Key{Code: keys.RuneKey, Runes: group}) } - return keys.Key{AltPressed: true, Code: keys.RuneKey, Runes: []rune{c}}, nil + group = nil } - var runes []rune - b := buf[:numBytes] + for pos < len(input) { + rest := input[pos:] - // Translate stdin into runes. - for i, w := 0, 0; i < len(b); i += w { //nolint:wastedassign - r, width := utf8.DecodeRune(b[i:]) - if r == utf8.RuneError { - return keys.Key{}, fmt.Errorf("could not decode rune: %w", err) + if key, n, ok := matchSequence(rest); ok { + flush() + + parsed = append(parsed, key) + pos += n + + continue } - runes = append(runes, r) - w = width - } + if !atEOF && (escapePrefixes[string(rest)] || !utf8.FullRune(rest)) { + break + } - if len(runes) == 0 { - return keys.Key{}, fmt.Errorf("received 0 runes from stdin") - } else if len(runes) > 1 { - return keys.Key{Code: keys.RuneKey, Runes: runes}, nil - } + if rest[0] == internal.KeyEscape && len(rest) > 1 { + // An escape followed by another key means the key was typed with alt held down. + if r, size := utf8.DecodeRune(rest[1:]); r != utf8.RuneError { + flush() + + key := runeKey(r) + key.AltPressed = true + parsed = append(parsed, key) + pos += 1 + size + + continue + } + } + + r, size := utf8.DecodeRune(rest) + pos += size + + if r == utf8.RuneError && size <= 1 { + continue + } + + key := runeKey(r) + if groupRunes && (key.Code == keys.RuneKey || key.Code == keys.Space) { + group = append(group, r) + + continue + } + + flush() - r := keys.KeyCode(runes[0]) - if numBytes == 1 && r <= internal.KeyUnitSeparator || r == internal.KeyDelete { - return keys.Key{Code: r}, nil + parsed = append(parsed, key) } - if runes[0] == ' ' { - return keys.Key{Code: keys.Space, Runes: runes}, nil + flush() + + return parsed, pos +} + +// matchSequence returns the key for the longest escape sequence at the start of input. +func matchSequence(input []byte) (keys.Key, int, bool) { + for n := min(len(input), maxSequenceLen); n >= 1; n-- { + if key, ok := escapeSequences[string(input[:n])]; ok { + return key, n, true + } } - return keys.Key{Code: keys.RuneKey, Runes: runes}, nil + return keys.Key{}, 0, false +} + +// runeKey returns the key for a single rune. +func runeKey(r rune) keys.Key { + switch { + case r == ' ': + return keys.Key{Code: keys.Space, Runes: []rune{r}} + case r <= internal.KeyUnitSeparator || r == internal.KeyDelete: + return keys.Key{Code: keys.KeyCode(r)} + default: + return keys.Key{Code: keys.RuneKey, Runes: []rune{r}} + } } diff --git a/keyboard.go b/keyboard.go index 59faf02..262f937 100644 --- a/keyboard.go +++ b/keyboard.go @@ -1,60 +1,68 @@ package keyboard import ( + "errors" "fmt" - "os" + "io" "sync" "sync/atomic" - - "github.com/containerd/console" + "time" "atomicgo.dev/keyboard/keys" ) -var con console.Console -var stdin = os.Stdin -var inputTTY *os.File -var mockChannel = make(chan keys.Key) +// ErrAlreadyListening is returned by Listen when another Listen call is still running. +// Only one listener can own the terminal and the simulated key presses at a time. +var ErrAlreadyListening = errors.New("keyboard: a listener is already running") -var mocking = false +// errNoTerminal is returned by openTerminal when stdin is not an interactive terminal. +var errNoTerminal = errors.New("keyboard: stdin is not a terminal") -func startListener() error { - err := initInput() - if err != nil { - return err - } +// errCanceled is returned by terminal.Read after terminal.Cancel was called. +var errCanceled = errors.New("keyboard: read canceled") - if mocking { - return nil - } +// readerShutdownTimeout bounds how long Listen waits for the terminal reader to +// return after it was canceled. On Unix the reader always wakes up immediately; +// the timeout only guards platforms where canceling a blocked read is best effort. +const readerShutdownTimeout = time.Second - if con != nil { - if err = con.SetRaw(); err != nil { - return fmt.Errorf("failed to set raw mode: %w", err) - } - } +var ( + listening atomic.Bool + mockChannel = make(chan keys.Key) - inputTTY, err = openInputTTY() - if err != nil { - return err - } + // openTerminal is a variable so tests can replace the system terminal. + openTerminal = openSystemTerminal +) - return nil +// terminal is an interactive input device that has been switched to raw mode. +type terminal interface { + // Read blocks until input is available. It returns errCanceled after Cancel was called. + Read(p []byte) (int, error) + // Cancel unblocks a pending Read. It is safe to call from another goroutine. + Cancel() + // Close restores the original terminal mode and releases all resources. + Close() error } -func stopListener() error { - if con != nil { - err := con.Reset() - if err != nil { - return fmt.Errorf("failed to reset console: %w", err) - } - } - - return restoreInput() +type readResult struct { + keys []keys.Key + err error } // Listen calls a callback function when a key is pressed. // +// The terminal is switched to raw mode while Listen runs, so Ctrl+C is delivered +// to the callback as keys.CtrlC instead of interrupting the program. The previous +// terminal mode is always restored before Listen returns, including when the +// callback returns an error or panics. +// +// Key presses sent with SimulateKeyPress are delivered as well. If stdin is not a +// terminal, only simulated key presses are delivered. +// +// Listen can be called any number of times, one after another. It returns +// ErrAlreadyListening if it is called while another Listen call is still running, +// for example from inside the callback. +// // Simple example: // // keyboard.Listen(func(key keys.Key) (stop bool, err error) { @@ -65,107 +73,171 @@ func stopListener() error { // fmt.Println("\r" + key.String()) // Print every key press // return false, nil // Return false to continue listening // }) -func Listen(onKeyPress func(key keys.Key) (stop bool, err error)) error { - var ( - callbackMu sync.Mutex - closeOnce sync.Once - stopOnce sync.Once - stopRoutine atomic.Bool - ) - - stopped := make(chan struct{}) - stop := func() { - stopOnce.Do(func() { - stopRoutine.Store(true) - closeOnce.Do(func() { - closeInput() - - if inputTTY != nil { - _ = inputTTY.Close() - } - }) - close(stopped) - }) +func Listen(onKeyPress func(key keys.Key) (stop bool, err error)) (err error) { + if !listening.CompareAndSwap(false, true) { + return ErrAlreadyListening } + defer listening.Store(false) - handleKeyPress := func(key keys.Key) (bool, error) { - callbackMu.Lock() - defer callbackMu.Unlock() + var terminalKeys <-chan readResult - shouldStop, err := onKeyPress(key) - if shouldStop { - stop() - } + term, openErr := openTerminal() - return shouldStop, err - } + switch { + case openErr == nil: + results := make(chan readResult) + done := make(chan struct{}) + readerDone := make(chan struct{}) - err := startListener() - if err != nil { - if err.Error() != "provided file is not a console" { - return err - } - } + go func() { + defer close(readerDone) - cancel := make(chan struct{}) - var mockWG sync.WaitGroup - mockWG.Add(1) + readTerminal(term, results, done) + }() - go func() { - defer mockWG.Done() + defer func() { + close(done) + term.Cancel() - for { select { - case <-cancel: - return - - case keyInfo := <-mockChannel: - shouldStop, _ := handleKeyPress(keyInfo) - if shouldStop { - return - } + case <-readerDone: + case <-time.After(readerShutdownTimeout): } - } - }() - - defer func() { - close(cancel) - mockWG.Wait() - }() - - if inputTTY == nil { - <-stopped - } else { - for !stopRoutine.Load() { - key, keyErr := getKeyPress() - if keyErr != nil { - return keyErr + + if closeErr := term.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("keyboard: failed to restore terminal: %w", closeErr) } + }() + + terminalKeys = results + + case errors.Is(openErr, errNoTerminal): + // Without a terminal only simulated key presses can arrive. + default: + return openErr + } + + for { + var pressed []keys.Key - if stopRoutine.Load() { - break + select { + case key := <-mockChannel: + pressed = []keys.Key{key} + case result := <-terminalKeys: + if result.err != nil { + return result.err } - shouldStop, stopErr := handleKeyPress(key) - if stopErr != nil { - return stopErr + pressed = result.keys + } + + for _, key := range pressed { + stop, callbackErr := onKeyPress(key) + if callbackErr != nil { + return callbackErr } - if shouldStop { - break + if stop { + return nil } } } +} - err = stopListener() - if err != nil { - return err +// readTerminal reads key presses from term and sends them to results until done is closed. +func readTerminal(term terminal, results chan<- readResult, done <-chan struct{}) { + var buf [256]byte + + for { + n, err := term.Read(buf[:]) + if errors.Is(err, errCanceled) { + return + } + + var result readResult + + if err != nil { + result.err = fmt.Errorf("keyboard: failed to read from terminal: %w", err) + } else { + // Every read from a raw terminal holds complete key presses, so the + // buffer is parsed as if no more input followed. + result.keys, _ = parseKeys(buf[:n], true, true) + } + + if len(result.keys) == 0 && result.err == nil { + continue + } + + select { + case results <- result: + case <-done: + return + } + + if result.err != nil { + return + } } +} - return nil +// ListenReader calls a callback function for every key press read from r. +// +// Unlike Listen, it does not touch the terminal and ignores simulated key presses. +// It is meant for tests and for input that is not an interactive terminal, such as +// a pipe. Every rune and every escape sequence in the input is delivered as a +// separate key, and both "\r" and "\n" are delivered as keys.Enter. +// +// ListenReader returns nil when the callback stops it, the callback's error if it +// returns one, and io.EOF if r is exhausted before the callback stops it. +// +// Example: +// +// keyboard.ListenReader(strings.NewReader("y\r"), func(key keys.Key) (stop bool, err error) { +// fmt.Println(key) // Prints "y", then "enter" +// return key.Code == keys.Enter, nil +// }) +func ListenReader(r io.Reader, onKeyPress func(key keys.Key) (stop bool, err error)) error { + var ( + pending []byte + chunk [256]byte + ) + + for { + n, readErr := r.Read(chunk[:]) + pending = append(pending, chunk[:n]...) + atEOF := errors.Is(readErr, io.EOF) + + parsed, consumed := parseKeys(pending, atEOF, false) + pending = append(pending[:0], pending[consumed:]...) + + for _, key := range parsed { + if key.Code == keys.CtrlJ { + key.Code = keys.Enter + } + + stop, callbackErr := onKeyPress(key) + if callbackErr != nil { + return callbackErr + } + + if stop { + return nil + } + } + + if atEOF { + return io.EOF + } + + if readErr != nil { + return fmt.Errorf("keyboard: failed to read input: %w", readErr) + } + } } // SimulateKeyPress simulate a key press. It can be used to mock user stdin and test your application. +// It accepts runes, strings (one key press per rune), keys.KeyCode and keys.Key values, +// and blocks until a running Listen call has received every key press. // // Example: // @@ -176,45 +248,37 @@ func Listen(onKeyPress func(key keys.Key) (stop bool, err error)) error { // keyboard.SimulateKeyPress('x') // Simulate key press for a single rune // keyboard.SimulateKeyPress('x', keys.Down, 'a') // Simulate key presses for multiple inputs // }() -func SimulateKeyPress(input ...interface{}) error { - for _, key := range input { - // Check if key is a keys.Key - if key, ok := key.(keys.Key); ok { +func SimulateKeyPress(input ...any) error { + for _, in := range input { + switch key := in.(type) { + case keys.Key: mockChannel <- key - return nil - } - - // Check if key is a rune - if key, ok := key.(rune); ok { - mockChannel <- keys.Key{ - Code: keys.RuneKey, - Runes: []rune{key}, - } - - return nil - } - - // Check if key is a string - if key, ok := key.(string); ok { + case rune: + mockChannel <- keys.Key{Code: keys.RuneKey, Runes: []rune{key}} + case string: for _, r := range key { - mockChannel <- keys.Key{ - Code: keys.RuneKey, - Runes: []rune{r}, - } - } - - return nil - } - - // Check if key is a KeyCode - if key, ok := key.(keys.KeyCode); ok { - mockChannel <- keys.Key{ - Code: key, + mockChannel <- keys.Key{Code: keys.RuneKey, Runes: []rune{r}} } - return nil + case keys.KeyCode: + mockChannel <- keys.Key{Code: key} + default: + return fmt.Errorf("keyboard: cannot simulate key press of type %T", in) } } return nil } + +// cancelOnce is embedded by terminal implementations to make Cancel idempotent. +type cancelOnce struct { + once sync.Once + canceled atomic.Bool +} + +func (c *cancelOnce) cancel(f func()) { + c.once.Do(func() { + c.canceled.Store(true) + f() + }) +} diff --git a/keyboard_test.go b/keyboard_test.go index 0ad1a7b..06095f2 100644 --- a/keyboard_test.go +++ b/keyboard_test.go @@ -1,91 +1,390 @@ -package keyboard_test +package keyboard import ( + "errors" + "io" + "os" + "reflect" + "strings" + "sync" "testing" + "testing/iotest" + "time" - "atomicgo.dev/keyboard" "atomicgo.dev/keyboard/keys" - "github.com/MarvinJWendt/testza" ) -func TestMocking(t *testing.T) { - simulateErrs := make(chan error, 4) +func TestMain(m *testing.M) { + // Never touch the terminal the tests run in. Tests that need terminal input + // install a fakeTerminal instead. + openTerminal = noTerminal + + os.Exit(m.Run()) +} + +func noTerminal() (terminal, error) { + return nil, errNoTerminal +} + +// fakeTerminal is an in-memory terminal that records whether it was restored. +type fakeTerminal struct { + cancelOnce + + input chan []byte + readErr error + done chan struct{} + + mu sync.Mutex + closed int +} + +func newFakeTerminal(input ...string) *fakeTerminal { + ft := &fakeTerminal{ + input: make(chan []byte, len(input)), + done: make(chan struct{}), + } + + for _, in := range input { + ft.input <- []byte(in) + } + + return ft +} + +func (ft *fakeTerminal) Read(p []byte) (int, error) { + select { + case in := <-ft.input: + return copy(p, in), nil + case <-ft.done: + return 0, errCanceled + default: + } + + if ft.readErr != nil { + return 0, ft.readErr + } + + select { + case in := <-ft.input: + return copy(p, in), nil + case <-ft.done: + return 0, errCanceled + } +} + +func (ft *fakeTerminal) Cancel() { + ft.cancel(func() { close(ft.done) }) +} + +func (ft *fakeTerminal) Close() error { + ft.mu.Lock() + defer ft.mu.Unlock() + + ft.closed++ + + return nil +} + +func (ft *fakeTerminal) closeCount() int { + ft.mu.Lock() + defer ft.mu.Unlock() + + return ft.closed +} + +func useTerminal(t *testing.T, ft *fakeTerminal) { + t.Helper() + + openTerminal = func() (terminal, error) { return ft, nil } + + t.Cleanup(func() { openTerminal = noTerminal }) +} + +// collect returns a callback that records every key and stops at stopCode. +func collect(got *[]keys.Key, stopCode keys.KeyCode) func(keys.Key) (bool, error) { + return func(key keys.Key) (bool, error) { + *got = append(*got, key) + + return key.Code == stopCode, nil + } +} + +func simulate(t *testing.T, input ...any) { + t.Helper() + + errs := make(chan error, 1) go func() { - defer close(simulateErrs) + errs <- SimulateKeyPress(input...) + }() - if err := keyboard.SimulateKeyPress('a'); err != nil { - simulateErrs <- err + t.Cleanup(func() { + select { + case err := <-errs: + if err != nil { + t.Errorf("SimulateKeyPress: %v", err) + } - return + case <-time.After(5 * time.Second): + t.Error("simulated key presses were not consumed") } + }) +} - if err := keyboard.SimulateKeyPress("b"); err != nil { - simulateErrs <- err +func rk(s string) keys.Key { + return keys.Key{Code: keys.RuneKey, Runes: []rune(s)} +} - return - } +func TestSimulateKeyPress(t *testing.T) { + simulate(t, 'a', "bc", keys.Key{Code: keys.Up, AltPressed: true}, keys.Enter) - if err := keyboard.SimulateKeyPress("c"); err != nil { - simulateErrs <- err + var got []keys.Key - return + if err := Listen(collect(&got, keys.Enter)); err != nil { + t.Fatalf("Listen returned an error: %v", err) + } + + want := []keys.Key{rk("a"), rk("b"), rk("c"), {Code: keys.Up, AltPressed: true}, {Code: keys.Enter}} + if !reflect.DeepEqual(got, want) { + t.Errorf("got keys %v, want %v", got, want) + } +} + +func TestSimulateKeyPressUnsupportedType(t *testing.T) { + if err := SimulateKeyPress(3.14); err == nil { + t.Error("expected an error for an unsupported input type") + } +} + +func TestListenCanBeCalledRepeatedly(t *testing.T) { + ft := newFakeTerminal() + useTerminal(t, ft) + + for i := range 5 { + simulate(t, "x", keys.Enter) + + var got []keys.Key + + if err := Listen(collect(&got, keys.Enter)); err != nil { + t.Fatalf("Listen #%d returned an error: %v", i, err) } - if err := keyboard.SimulateKeyPress(keys.Enter); err != nil { - simulateErrs <- err + if len(got) != 2 { + t.Fatalf("Listen #%d got %v, want two keys", i, got) } + } +} + +func TestListenRestoresTerminalOnCallbackError(t *testing.T) { + ft := newFakeTerminal("q") + useTerminal(t, ft) + + wantErr := errors.New("callback failed") + + err := Listen(func(keys.Key) (bool, error) { + return false, wantErr + }) + if !errors.Is(err, wantErr) { + t.Errorf("got error %v, want %v", err, wantErr) + } + + if ft.closeCount() != 1 { + t.Errorf("terminal was restored %d times, want 1", ft.closeCount()) + } +} + +func TestListenRestoresTerminalOnPanic(t *testing.T) { + ft := newFakeTerminal("q") + useTerminal(t, ft) + + func() { + defer func() { + if recover() == nil { + t.Error("expected the callback panic to propagate") + } + }() + + _ = Listen(func(keys.Key) (bool, error) { + panic("boom") + }) }() - var aPressed, bPressed, cPressed, enterPressed bool - var keyList []keys.Key + if ft.closeCount() != 1 { + t.Errorf("terminal was restored %d times, want 1", ft.closeCount()) + } - err := keyboard.Listen(func(key keys.Key) (stop bool, err error) { - keyList = append(keyList, key) - switch key.Code { - case keys.RuneKey: - switch key.String() { - case "a": - println("a pressed") + // The panic must not leave the listener marked as running. + simulate(t, keys.Enter) - aPressed = true + if err := Listen(collect(new([]keys.Key), keys.Enter)); err != nil { + t.Errorf("Listen after a panic returned an error: %v", err) + } +} - return false, nil +func TestListenDeliversTerminalInput(t *testing.T) { + ft := newFakeTerminal("\x1b[A", "a", "\x1b[1;5C", "\x03") + useTerminal(t, ft) - case "b": - println("b pressed") + var got []keys.Key - bPressed = true + if err := Listen(collect(&got, keys.CtrlC)); err != nil { + t.Fatalf("Listen returned an error: %v", err) + } - return false, nil + want := []keys.Key{{Code: keys.Up}, rk("a"), {Code: keys.CtrlRight}, {Code: keys.CtrlC}} + if !reflect.DeepEqual(got, want) { + t.Errorf("got keys %v, want %v", got, want) + } - case "c": - println("c pressed") + if ft.closeCount() != 1 { + t.Errorf("terminal was restored %d times, want 1", ft.closeCount()) + } +} - cPressed = true +func TestListenReturnsTerminalReadError(t *testing.T) { + ft := newFakeTerminal() + ft.readErr = io.ErrUnexpectedEOF + useTerminal(t, ft) - return false, nil - } + err := Listen(collect(new([]keys.Key), keys.Enter)) + if !errors.Is(err, io.ErrUnexpectedEOF) { + t.Errorf("got error %v, want %v", err, io.ErrUnexpectedEOF) + } + + if ft.closeCount() != 1 { + t.Errorf("terminal was restored %d times, want 1", ft.closeCount()) + } +} - case keys.Enter: - println("enter pressed") +func TestListenWhileListening(t *testing.T) { + simulate(t, keys.Enter) - enterPressed = true + var nestedErr error - return true, nil - } + err := Listen(func(keys.Key) (bool, error) { + nestedErr = Listen(collect(new([]keys.Key), keys.Enter)) - return false, nil + return true, nil }) + if err != nil { + t.Fatalf("Listen returned an error: %v", err) + } - testza.AssertNoError(t, err) + if !errors.Is(nestedErr, ErrAlreadyListening) { + t.Errorf("nested Listen returned %v, want %v", nestedErr, ErrAlreadyListening) + } +} - for err := range simulateErrs { - t.Errorf("failed to simulate key press: %v", err) +func TestListenReader(t *testing.T) { + tests := []struct { + name string + input string + want []keys.Key + }{ + {"runes", "ab\r", []keys.Key{rk("a"), rk("b"), {Code: keys.Enter}}}, + {"newline is enter", "a\n", []keys.Key{rk("a"), {Code: keys.Enter}}}, + {"space", " \r", []keys.Key{{Code: keys.Space, Runes: []rune{' '}}, {Code: keys.Enter}}}, + {"unicode", "รครŸ๐Ÿ˜€\r", []keys.Key{rk("รค"), rk("รŸ"), rk("๐Ÿ˜€"), {Code: keys.Enter}}}, + {"arrows", "\x1b[A\x1b[B\x1b[1;6D\r", []keys.Key{{Code: keys.Up}, {Code: keys.Down}, {Code: keys.CtrlShiftLeft}, {Code: keys.Enter}}}, + {"alt", "\x1bx\x1b\x1b[C\r", []keys.Key{{Code: keys.RuneKey, Runes: []rune{'x'}, AltPressed: true}, {Code: keys.Right, AltPressed: true}, {Code: keys.Enter}}}, + {"backspace and tab", "\x7f\x08\t\r", []keys.Key{{Code: keys.Backspace}, {Code: keys.Backspace}, {Code: keys.Tab}, {Code: keys.Enter}}}, + {"function keys", "\x1bOP\x1b[24~\r", []keys.Key{{Code: keys.F1}, {Code: keys.F12}, {Code: keys.Enter}}}, + {"escape", "\x1b", []keys.Key{{Code: keys.Escape}}}, + {"ctrl+c", "\x03", []keys.Key{{Code: keys.CtrlC}}}, + {"invalid utf-8 is skipped", "a\xffb\r", []keys.Key{rk("a"), rk("b"), {Code: keys.Enter}}}, } - testza.AssertTrue(t, aPressed, "A | %s", keyList) - testza.AssertTrue(t, bPressed, "B | %s", keyList) - testza.AssertTrue(t, cPressed, "C | %s", keyList) - testza.AssertTrue(t, enterPressed, "Enter | %s", keyList) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + readers := map[string]io.Reader{ + "whole": strings.NewReader(tt.input), + "one byte": iotest.OneByteReader(strings.NewReader(tt.input)), + } + + for readerName, r := range readers { + var got []keys.Key + + err := ListenReader(r, func(key keys.Key) (bool, error) { + got = append(got, key) + + return false, nil + }) + if !errors.Is(err, io.EOF) { + t.Errorf("%s: got error %v, want io.EOF", readerName, err) + } + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%s: got keys %v, want %v", readerName, got, tt.want) + } + } + }) + } +} + +func TestListenReaderStops(t *testing.T) { + var got []keys.Key + + err := ListenReader(strings.NewReader("y\rignored"), collect(&got, keys.Enter)) + if err != nil { + t.Fatalf("ListenReader returned an error: %v", err) + } + + if want := []keys.Key{rk("y"), {Code: keys.Enter}}; !reflect.DeepEqual(got, want) { + t.Errorf("got keys %v, want %v", got, want) + } +} + +func TestListenReaderErrors(t *testing.T) { + wantErr := errors.New("callback failed") + + err := ListenReader(strings.NewReader("a"), func(keys.Key) (bool, error) { return false, wantErr }) + if !errors.Is(err, wantErr) { + t.Errorf("got error %v, want %v", err, wantErr) + } + + readErr := errors.New("read failed") + + err = ListenReader(iotest.ErrReader(readErr), collect(new([]keys.Key), keys.Enter)) + if !errors.Is(err, readErr) { + t.Errorf("got error %v, want %v", err, readErr) + } +} + +func TestParseKeysGroupsPastedRunes(t *testing.T) { + got, n := parseKeys([]byte("hello world\x1b[A "), true, true) + + want := []keys.Key{rk("hello world"), {Code: keys.Up}, {Code: keys.Space, Runes: []rune{' '}}} + if !reflect.DeepEqual(got, want) { + t.Errorf("got keys %v, want %v", got, want) + } + + if n != 15 { + t.Errorf("consumed %d bytes, want 15", n) + } +} + +func TestParseKeysWaitsForIncompleteInput(t *testing.T) { + for _, input := range []string{"\x1b", "\x1b[", "\x1b[1;5", "\xc3"} { + got, n := parseKeys([]byte("a"+input), false, false) + if n != 1 || len(got) != 1 { + t.Errorf("%q: parsed %v and consumed %d bytes, want only the leading rune", input, got, n) + } + } +} + +func TestKeyString(t *testing.T) { + tests := map[string]keys.Key{ + "a": rk("a"), + "alt+a": {Code: keys.RuneKey, Runes: []rune{'a'}, AltPressed: true}, + "enter": {Code: keys.Enter}, + "ctrl+c": {Code: keys.CtrlC}, + "alt+up": {Code: keys.Up, AltPressed: true}, + "backspace": {Code: keys.Backspace}, + } + + for want, key := range tests { + if got := key.String(); got != want { + t.Errorf("%#v.String() = %q, want %q", key, got, want) + } + } } diff --git a/terminal_linux_test.go b/terminal_linux_test.go new file mode 100644 index 0000000..2daf4e9 --- /dev/null +++ b/terminal_linux_test.go @@ -0,0 +1,120 @@ +package keyboard + +import ( + "errors" + "strconv" + "testing" + "time" + + "golang.org/x/sys/unix" + "golang.org/x/term" +) + +// openPTY opens a pseudo terminal pair and returns the controller and the terminal side. +func openPTY(t *testing.T) (int, int) { + t.Helper() + + controller, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0) + if err != nil { + t.Skipf("pseudo terminals are not available: %v", err) + } + + t.Cleanup(func() { _ = unix.Close(controller) }) + + err = unix.IoctlSetPointerInt(controller, unix.TIOCSPTLCK, 0) + if err != nil { + t.Fatalf("failed to unlock pty: %v", err) + } + + number, err := unix.IoctlGetInt(controller, unix.TIOCGPTN) + if err != nil { + t.Fatalf("failed to get pty number: %v", err) + } + + name := "/dev/pts/" + strconv.Itoa(number) + + tty, err := unix.Open(name, unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0) + if err != nil { + t.Fatalf("failed to open %s: %v", name, err) + } + + t.Cleanup(func() { _ = unix.Close(tty) }) + + return controller, tty +} + +func TestUnixTerminal(t *testing.T) { + controller, tty := openPTY(t) + + before, err := unix.IoctlGetTermios(tty, unix.TCGETS) + if err != nil { + t.Fatal(err) + } + + ut, err := newUnixTerminal(tty, false) + if err != nil { + t.Fatal(err) + } + + raw, err := unix.IoctlGetTermios(tty, unix.TCGETS) + if err != nil { + t.Fatal(err) + } + + if raw.Lflag&(unix.ICANON|unix.ECHO|unix.ISIG) != 0 { + t.Error("terminal is not in raw mode") + } + + // Ctrl+C has to arrive as a byte instead of raising SIGINT. + _, err = unix.Write(controller, []byte{0x03}) + if err != nil { + t.Fatal(err) + } + + buf := make([]byte, 16) + + n, err := ut.Read(buf) + if err != nil || n != 1 || buf[0] != 0x03 { + t.Errorf("Read() = %q, %v, want ctrl+c", buf[:n], err) + } + + // Cancel has to unblock a pending read. + readErr := make(chan error, 1) + + go func() { + _, cancelErr := ut.Read(buf) + readErr <- cancelErr + }() + + time.Sleep(50 * time.Millisecond) + ut.Cancel() + ut.Cancel() + + select { + case cancelErr := <-readErr: + if !errors.Is(cancelErr, errCanceled) { + t.Errorf("canceled Read returned %v, want errCanceled", cancelErr) + } + + case <-time.After(5 * time.Second): + t.Fatal("Cancel did not unblock Read") + } + + err = ut.Close() + if err != nil { + t.Fatalf("Close returned an error: %v", err) + } + + after, err := unix.IoctlGetTermios(tty, unix.TCGETS) + if err != nil { + t.Fatal(err) + } + + if after.Lflag != before.Lflag || after.Iflag != before.Iflag || after.Oflag != before.Oflag { + t.Error("terminal mode was not restored") + } + + if !term.IsTerminal(tty) { + t.Error("terminal file descriptor was closed although it is not owned") + } +} diff --git a/terminal_other.go b/terminal_other.go new file mode 100644 index 0000000..38952c9 --- /dev/null +++ b/terminal_other.go @@ -0,0 +1,9 @@ +//go:build !(aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || windows) + +package keyboard + +// openSystemTerminal reports that raw terminal input is not supported on this +// platform, so Listen only delivers simulated key presses. +func openSystemTerminal() (terminal, error) { + return nil, errNoTerminal +} diff --git a/terminal_unix.go b/terminal_unix.go new file mode 100644 index 0000000..fb47af4 --- /dev/null +++ b/terminal_unix.go @@ -0,0 +1,136 @@ +//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris + +package keyboard + +import ( + "errors" + "fmt" + "io" + "os" + + "golang.org/x/sys/unix" + "golang.org/x/term" +) + +// unixTerminal reads raw input from a terminal file descriptor. +// +// Reads wait in select(2) on the terminal and on a wake-up pipe, so Cancel can +// interrupt a pending Read without closing the terminal. select is used instead +// of poll because poll does not support terminal devices on macOS. +type unixTerminal struct { + cancelOnce + + fd int + ownsFD bool + state *term.State + wakeRead *os.File + wakeFD int + wake *os.File +} + +func openSystemTerminal() (terminal, error) { + if !term.IsTerminal(unix.Stdin) { + return nil, errNoTerminal + } + + // Prefer the controlling terminal, so stdin stays untouched for the program. + fd, err := unix.Open("/dev/tty", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0) + if err != nil { + return newUnixTerminal(unix.Stdin, false) + } + + t, err := newUnixTerminal(fd, true) + if err != nil { + _ = unix.Close(fd) + + return nil, err + } + + return t, nil +} + +func newUnixTerminal(fd int, ownsFD bool) (*unixTerminal, error) { + wakeRead, wake, err := os.Pipe() + if err != nil { + return nil, fmt.Errorf("keyboard: failed to create wake-up pipe: %w", err) + } + + state, err := term.MakeRaw(fd) + if err != nil { + _ = wakeRead.Close() + _ = wake.Close() + + return nil, fmt.Errorf("keyboard: failed to set raw mode: %w", err) + } + + return &unixTerminal{ + fd: fd, + ownsFD: ownsFD, + state: state, + wakeRead: wakeRead, + wakeFD: int(wakeRead.Fd()), + wake: wake, + }, nil +} + +func (t *unixTerminal) Read(p []byte) (int, error) { + for { + if t.canceled.Load() { + return 0, errCanceled + } + + var readSet unix.FdSet + + readSet.Zero() + readSet.Set(t.fd) + readSet.Set(t.wakeFD) + + _, err := unix.Select(max(t.fd, t.wakeFD)+1, &readSet, nil, nil, nil) + if errors.Is(err, unix.EINTR) { + continue + } + + if err != nil { + return 0, err + } + + if readSet.IsSet(t.wakeFD) { + return 0, errCanceled + } + + if !readSet.IsSet(t.fd) { + continue + } + + n, err := unix.Read(t.fd, p) + if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) { + continue + } + + if err != nil { + return 0, err + } + + if n == 0 { + return 0, io.EOF + } + + return n, nil + } +} + +func (t *unixTerminal) Cancel() { + t.cancel(func() { + _, _ = t.wake.Write([]byte{0}) + }) +} + +func (t *unixTerminal) Close() error { + err := term.Restore(t.fd, t.state) + + if t.ownsFD { + err = errors.Join(err, unix.Close(t.fd)) + } + + return errors.Join(err, t.wake.Close(), t.wakeRead.Close()) +} diff --git a/terminal_windows.go b/terminal_windows.go new file mode 100644 index 0000000..c4a6259 --- /dev/null +++ b/terminal_windows.go @@ -0,0 +1,69 @@ +//go:build windows + +package keyboard + +import ( + "errors" + "fmt" + "os" + + "golang.org/x/sys/windows" + "golang.org/x/term" +) + +// windowsTerminal reads raw input from the console input buffer (CONIN$). +// Cancel interrupts a pending Read with CancelIoEx. +type windowsTerminal struct { + cancelOnce + + file *os.File + handle windows.Handle + state *term.State +} + +func openSystemTerminal() (terminal, error) { + if !term.IsTerminal(int(windows.Stdin)) { + return nil, errNoTerminal + } + + file, err := os.OpenFile("CONIN$", os.O_RDWR, 0) + if err != nil { + return nil, fmt.Errorf("keyboard: failed to open console input: %w", err) + } + + handle := windows.Handle(file.Fd()) + + // Raw mode turns off processed input, so Ctrl+C arrives as a key press + // instead of a signal, and turns on virtual terminal input sequences. + state, err := term.MakeRaw(int(handle)) + if err != nil { + _ = file.Close() + + return nil, fmt.Errorf("keyboard: failed to set raw mode: %w", err) + } + + return &windowsTerminal{file: file, handle: handle, state: state}, nil +} + +func (t *windowsTerminal) Read(p []byte) (int, error) { + if t.canceled.Load() { + return 0, errCanceled + } + + n, err := t.file.Read(p) + if err != nil && t.canceled.Load() { + return 0, errCanceled + } + + return n, err +} + +func (t *windowsTerminal) Cancel() { + t.cancel(func() { + _ = windows.CancelIoEx(t.handle, nil) + }) +} + +func (t *windowsTerminal) Close() error { + return errors.Join(term.Restore(int(t.handle), t.state), t.file.Close()) +} diff --git a/tty_unix.go b/tty_unix.go deleted file mode 100644 index 79802e4..0000000 --- a/tty_unix.go +++ /dev/null @@ -1,38 +0,0 @@ -//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris -// +build darwin dragonfly freebsd linux netbsd openbsd solaris - -package keyboard - -import ( - "os" - - "github.com/containerd/console" -) - -func restoreInput() error { - if con != nil { - return con.Reset() - } - - return nil -} - -func initInput() error { - c, err := console.ConsoleFromFile(stdin) - if err != nil { - return err - } - - con = c - - return nil -} - -func openInputTTY() (*os.File, error) { - f, err := os.Open("/dev/tty") - if err != nil { - return nil, err - } - - return f, nil -} diff --git a/tty_windows.go b/tty_windows.go deleted file mode 100644 index ec0704a..0000000 --- a/tty_windows.go +++ /dev/null @@ -1,49 +0,0 @@ -//go:build windows -// +build windows - -package keyboard - -import ( - "fmt" - "os" - "syscall" - - "github.com/containerd/console" -) - -var windowsStdin *os.File - -func restoreInput() error { - if windowsStdin != nil { - os.Stdin = windowsStdin - } - - return nil -} - -func initInput() error { - windowsStdin = os.Stdin - - os.Stdin = stdin - - var mode uint32 - err := syscall.GetConsoleMode(syscall.Stdin, &mode) - - if err != nil { - mocking = true - return nil - } - - con = console.Current() - - return nil -} - -func openInputTTY() (*os.File, error) { - f, err := os.OpenFile("CONIN$", os.O_RDWR, 0644) - if err != nil { - return nil, fmt.Errorf("failed to open stdin TTY: %w", err) - } - - return f, nil -} diff --git a/utils.go b/utils.go deleted file mode 100644 index 9b99626..0000000 --- a/utils.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris -// +build darwin dragonfly freebsd linux netbsd openbsd solaris - -package keyboard - -func closeInput() { - -} diff --git a/utils_windows.go b/utils_windows.go deleted file mode 100644 index a85cf43..0000000 --- a/utils_windows.go +++ /dev/null @@ -1,7 +0,0 @@ -package keyboard - -import "syscall" - -func closeInput() { - syscall.CancelIoEx(syscall.Handle(inputTTY.Fd()), nil) -}