//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) } } }() }