245 lines
6.0 KiB
Go
245 lines
6.0 KiB
Go
package main
|
|
|
|
// Minimal raw-terminal input for the interactive form (form.go). Deliberately
|
|
// Unix-only: puts the tty in raw mode via the external `stty` binary (same
|
|
// trick util.go's readPassword uses for -echo) instead of termios/ioctl
|
|
// syscalls, so there's no per-OS code - it works unchanged on Linux and
|
|
// macOS. Not supported on Windows (no /dev/tty, no stty); callers fall back
|
|
// to the plain key=value form there.
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// enterRawMode opens /dev/tty and switches it to raw, no-echo,
|
|
// character-at-a-time mode. The returned restore func must be called
|
|
// (typically via defer) to put the terminal back the way it was.
|
|
func enterRawMode() (tty *os.File, restore func(), err error) {
|
|
tty, err = os.OpenFile("/dev/tty", os.O_RDWR, 0)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
saved, err := runStty(tty, "-g")
|
|
if err != nil {
|
|
tty.Close()
|
|
return nil, nil, fmt.Errorf("stty: %w", err)
|
|
}
|
|
if _, err := runStty(tty, "raw", "-echo"); err != nil {
|
|
tty.Close()
|
|
return nil, nil, fmt.Errorf("stty: %w", err)
|
|
}
|
|
saved = strings.TrimSpace(saved)
|
|
restore = func() {
|
|
runStty(tty, saved)
|
|
tty.Close()
|
|
}
|
|
return tty, restore, nil
|
|
}
|
|
|
|
func runStty(tty *os.File, args ...string) (string, error) {
|
|
cmd := exec.Command("stty", args...)
|
|
cmd.Stdin = tty
|
|
out, err := cmd.Output()
|
|
return string(out), err
|
|
}
|
|
|
|
// ---- key reading ----
|
|
|
|
type specialKey int
|
|
|
|
const (
|
|
keyNone specialKey = iota
|
|
keyRune
|
|
keyUp
|
|
keyDown
|
|
keyLeft
|
|
keyRight
|
|
keyHome
|
|
keyEnd
|
|
keyDelete
|
|
keyBackspace
|
|
keyTab
|
|
keyShiftTab
|
|
keyEnter
|
|
keyCtrlS
|
|
keyCtrlC
|
|
keyEsc
|
|
)
|
|
|
|
type key struct {
|
|
special specialKey
|
|
r rune // valid when special == keyRune
|
|
}
|
|
|
|
// keyReader decodes raw tty bytes into keys, including ANSI escape
|
|
// sequences for arrows/home/end/delete and multi-byte UTF-8 runes. Reads
|
|
// happen on a background goroutine so an escape byte can be told apart from
|
|
// a full "ESC [ ..." sequence with a short timeout instead of blocking
|
|
// forever waiting for bytes that may never come.
|
|
type keyReader struct {
|
|
ch chan byte
|
|
}
|
|
|
|
func newKeyReader(r *os.File) *keyReader {
|
|
kr := &keyReader{ch: make(chan byte, 32)}
|
|
go func() {
|
|
buf := make([]byte, 1)
|
|
for {
|
|
n, err := r.Read(buf)
|
|
if n > 0 {
|
|
kr.ch <- buf[0]
|
|
}
|
|
if err != nil {
|
|
close(kr.ch)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
return kr
|
|
}
|
|
|
|
func (kr *keyReader) readByte() (byte, bool) {
|
|
b, ok := <-kr.ch
|
|
return b, ok
|
|
}
|
|
|
|
func (kr *keyReader) next() key {
|
|
b, ok := kr.readByte()
|
|
if !ok {
|
|
return key{special: keyCtrlC} // input closed - treat like cancel
|
|
}
|
|
switch b {
|
|
case 0x03:
|
|
return key{special: keyCtrlC}
|
|
case 0x13:
|
|
return key{special: keyCtrlS}
|
|
case 0x1b:
|
|
return kr.readEscape()
|
|
case '\r', '\n':
|
|
return key{special: keyEnter}
|
|
case 0x7f, 0x08:
|
|
return key{special: keyBackspace}
|
|
case 0x09:
|
|
return key{special: keyTab}
|
|
}
|
|
if b < 0x80 {
|
|
return key{special: keyRune, r: rune(b)}
|
|
}
|
|
n := utf8SeqLen(b)
|
|
buf := []byte{b}
|
|
for i := 1; i < n; i++ {
|
|
nb, ok := kr.readByte()
|
|
if !ok {
|
|
break
|
|
}
|
|
buf = append(buf, nb)
|
|
}
|
|
r, _ := utf8.DecodeRune(buf)
|
|
return key{special: keyRune, r: r}
|
|
}
|
|
|
|
// escDelay is how long readEscape waits for a byte to follow an 0x1b before
|
|
// calling it a lone Esc keypress. It's the one number the user actually
|
|
// feels: every Esc press pays it in full, since "nothing followed" can only
|
|
// be established by waiting. 50ms is comfortably below the ~100ms at which
|
|
// a delay starts reading as lag, while still being generous next to the
|
|
// sub-millisecond gap a terminal leaves between the bytes of one sequence
|
|
// (it's what tcell uses for the same job).
|
|
//
|
|
// Resist the urge to raise this to "fix" stray sequence bytes turning up as
|
|
// text: that symptom was chased here once (2026-08-20) and the timeout was
|
|
// never the cause - the bytes were being stolen by a second keyReader
|
|
// goroutine racing on the same terminal, see runForm's comment in form.go.
|
|
// A too-high value has a real cost beyond sluggishness: a genuine Esc that
|
|
// lands late still cancels the form, just later.
|
|
const escDelay = 50 * time.Millisecond
|
|
|
|
// readEscape is called right after an 0x1b byte. A lone Esc keypress won't
|
|
// be followed by anything (within escDelay), while an arrow/home/end/delete
|
|
// key sends "ESC [ ..." essentially instantaneously - that gap is what
|
|
// tells the two apart. Ctrl+C stays an always-instant cancel regardless
|
|
// (0x03 is handled directly in next(), no timeout involved).
|
|
func (kr *keyReader) readEscape() key {
|
|
var b2 byte
|
|
select {
|
|
case v, ok := <-kr.ch:
|
|
if !ok {
|
|
return key{special: keyEsc}
|
|
}
|
|
b2 = v
|
|
case <-time.After(escDelay):
|
|
return key{special: keyEsc}
|
|
}
|
|
if b2 != '[' {
|
|
return key{special: keyEsc}
|
|
}
|
|
|
|
// Read CSI parameter bytes (digits, ';', and the handful of other bytes
|
|
// ECMA-48 allows there: 0x30-0x3f) up to the byte that actually ends
|
|
// the sequence, however many there are, rather than assuming a count.
|
|
var params []byte
|
|
var final byte
|
|
for {
|
|
b, ok := kr.readByte()
|
|
if !ok {
|
|
return key{special: keyEsc}
|
|
}
|
|
if b >= 0x30 && b <= 0x3f {
|
|
params = append(params, b)
|
|
continue
|
|
}
|
|
final = b
|
|
break
|
|
}
|
|
|
|
switch final {
|
|
case 'A':
|
|
return key{special: keyUp}
|
|
case 'B':
|
|
return key{special: keyDown}
|
|
case 'C':
|
|
return key{special: keyRight}
|
|
case 'D':
|
|
return key{special: keyLeft}
|
|
case 'H':
|
|
return key{special: keyHome}
|
|
case 'F':
|
|
return key{special: keyEnd}
|
|
case 'Z':
|
|
return key{special: keyShiftTab}
|
|
case '~':
|
|
switch string(params) {
|
|
case "1":
|
|
return key{special: keyHome}
|
|
case "3":
|
|
return key{special: keyDelete}
|
|
case "4":
|
|
return key{special: keyEnd}
|
|
}
|
|
}
|
|
// A syntactically complete CSI sequence this app has no mapping for
|
|
// (an unsupported function key, an unrecognized modifier, ...) - every
|
|
// byte of it was consumed above, so there's nothing left to leak.
|
|
// Ignore it rather than treating it as Esc, which would cancel the form
|
|
// for a key the user never intended as "cancel".
|
|
return key{special: keyNone}
|
|
}
|
|
|
|
func utf8SeqLen(b byte) int {
|
|
switch {
|
|
case b&0xe0 == 0xc0:
|
|
return 2
|
|
case b&0xf0 == 0xe0:
|
|
return 3
|
|
case b&0xf8 == 0xf0:
|
|
return 4
|
|
default:
|
|
return 1
|
|
}
|
|
}
|