Files
terminal/backend_unix.go
T

184 lines
3.6 KiB
Go

//go:build unix
package terminal
import (
"errors"
"fmt"
"os"
"os/signal"
"syscall"
"golang.org/x/sys/unix"
"golang.org/x/term"
)
type unixBackend struct {
in *os.File
out *os.File
inFd int
outFd int
tty *os.File // the controlling terminal, opened for a redirected end
oldTerm *term.State
resizeStopCh chan struct{}
resizeDoneCh chan struct{}
}
const escapeTimeoutMs = 10
func newBackend() Backend {
return &unixBackend{
in: os.Stdin,
out: os.Stdout,
inFd: int(os.Stdin.Fd()),
outFd: int(os.Stdout.Fd()),
}
}
// Init draws on the controlling terminal in place of a redirected stdin or
// stdout, so `app > file` and `cmd | app` keep both as data
func (b *unixBackend) Init() error {
if os.Getenv("TERM") == "dumb" {
return errors.New("TERM=dumb cannot address the cursor")
}
if !term.IsTerminal(b.inFd) || !term.IsTerminal(b.outFd) {
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
if err != nil {
return fmt.Errorf("no terminal: %w", err)
}
b.tty = tty
if !term.IsTerminal(b.inFd) {
b.in, b.inFd = tty, int(tty.Fd())
}
if !term.IsTerminal(b.outFd) {
b.out, b.outFd = tty, int(tty.Fd())
}
}
old, err := term.MakeRaw(b.inFd)
if err != nil {
b.closeTTY()
return err
}
b.oldTerm = old
return nil
}
// closeTTY closes the controlling terminal Init opened, if any
func (b *unixBackend) closeTTY() {
if b.tty != nil {
b.tty.Close()
b.tty = nil
}
}
func (b *unixBackend) Fini() {
if b.resizeStopCh != nil {
close(b.resizeStopCh)
<-b.resizeDoneCh
b.resizeStopCh = nil
}
if b.oldTerm != nil {
term.Restore(b.inFd, b.oldTerm)
}
b.closeTTY()
}
// Size delegates to exported WindowSize; getTerminalSize deleted
func (b *unixBackend) Size() (int, int) {
if w, h, ok := WindowSize(b.out); ok {
return w, h
}
return 80, 24 // Fallback
}
// WindowSize queries terminal dimensions for f without raw mode or
// Terminal lifecycle. ok=false when f is not a terminal.
func WindowSize(f *os.File) (w, h int, ok bool) {
ws, err := unix.IoctlGetWinsize(int(f.Fd()), unix.TIOCGWINSZ)
if err != nil || ws.Col == 0 {
return 0, 0, false
}
return int(ws.Col), int(ws.Row), true
}
func (b *unixBackend) Write(p []byte) error {
_, err := b.out.Write(p)
return err
}
// Read implements the polling logic previously in input.go
func (b *unixBackend) Read(stopCh <-chan struct{}) ([]byte, error) {
// Buffer for single read
buf := make([]byte, 256)
for {
select {
case <-stopCh:
return nil, nil
default:
}
// Poll with timeout to allow checking stopCh
fds := []unix.PollFd{
{Fd: int32(b.inFd), Events: unix.POLLIN},
}
// Timeout to differentiate standalone ESC from escape sequences
n, err := unix.Poll(fds, escapeTimeoutMs)
if err != nil {
if errors.Is(err, unix.EINTR) {
continue
}
return nil, err
}
if n == 0 {
// Timeout - return empty to let readLoop handle pending ESC
return nil, nil
}
// Read data
rn, err := unix.Read(b.inFd, buf)
if err != nil {
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
continue
}
return nil, err
}
if rn == 0 {
// EOF
return nil, nil
}
// Return copy of data
ret := make([]byte, rn)
copy(ret, buf[:rn])
return ret, nil
}
}
func (b *unixBackend) SetResizeHandler(handler func(width, height int)) {
b.resizeStopCh = make(chan struct{})
b.resizeDoneCh = make(chan struct{})
go func() {
defer close(b.resizeDoneCh)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGWINCH)
defer signal.Stop(sigCh)
for {
select {
case <-b.resizeStopCh:
return
case <-sigCh:
w, h := b.Size()
handler(w, h)
}
}
}()
}