Files

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
}
}