From e2182d4dc6b566e71ab47c22e0676252349542f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 04:09:32 +0000 Subject: [PATCH 1/2] feat!: replace containerd/console with x/term and harden Listen Switch raw mode handling to golang.org/x/term and golang.org/x/sys on Unix and Windows, and drop the testza test dependency. The module graph now only contains golang.org/x/sys and golang.org/x/term. Listen now always restores the terminal, also when the callback returns an error or panics, can be called repeatedly, and returns ErrAlreadyListening for overlapping calls. Terminal reads can be canceled, so no reader goroutine outlives Listen. Escape sequences and control keys that arrive in one read are split into separate keys. ListenReader reads key presses from any io.Reader, for tests and non-interactive input. SimulateKeyPress now sends every argument instead of only the first one. BREAKING CHANGE: requires Go 1.27. Listen returns ErrAlreadyListening when called while another Listen is running. SimulateKeyPress returns an error for unsupported input types. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011Dno7CsMx2fSzNx5ZTm7RL --- doc.go | 43 +++-- examples_test.go | 17 ++ go.mod | 25 +-- go.sum | 93 +--------- input.go | 169 +++++++++++------ keyboard.go | 352 +++++++++++++++++++++--------------- keyboard_test.go | 401 +++++++++++++++++++++++++++++++++++------ terminal_linux_test.go | 120 ++++++++++++ terminal_other.go | 9 + terminal_unix.go | 136 ++++++++++++++ terminal_windows.go | 69 +++++++ tty_unix.go | 38 ---- tty_windows.go | 49 ----- utils.go | 8 - utils_windows.go | 7 - 15 files changed, 1061 insertions(+), 475 deletions(-) create mode 100644 terminal_linux_test.go create mode 100644 terminal_other.go create mode 100644 terminal_unix.go create mode 100644 terminal_windows.go delete mode 100644 tty_unix.go delete mode 100644 tty_windows.go delete mode 100644 utils.go delete mode 100644 utils_windows.go 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) -} From 9523c30aa177be6036b4a3c3e607416214e27ef9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 04:33:47 +0000 Subject: [PATCH 2/2] ci: build golangci-lint with the Go version from go.mod The v2.11.4 release binary is built with Go 1.26 and refuses to lint a module that targets Go 1.27. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011Dno7CsMx2fSzNx5ZTm7RL --- .github/workflows/go.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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