Files
junegunn--fzf/src/tui/light_unix.go
T
Junegunn Choi dab626bd9e Restore bracketed paste mode instead of forcing it off
Ask the terminal whether the mode is already on (DECRQM) and put it back
that way on exit. Forcing it off broke pasting in shells that run fzf
from a line editor widget, which enable the mode only when the editor
starts. Terminals that do not answer fall back to disabling it.

- Startup queries go out in one write, cursor position last. Every
  terminal answers DSR, so its reply bounds the wait: a paste reply
  still missing by then means the terminal does not know the query.
- Bound the first read with select(2). Terminals that never answer
  escape sequences, such as FreeBSD virtual terminals, blocked startup
  until a key was pressed, and that keystroke was then discarded.

Fix #4887
Fix #2860
Fix #976
2026-08-10 22:36:25 +09:00

271 lines
6.4 KiB
Go

//go:build !windows
package tui
import (
"errors"
"os"
"os/exec"
"strings"
"syscall"
"time"
"github.com/junegunn/fzf/src/util"
"golang.org/x/sys/unix"
"golang.org/x/term"
)
func IsLightRendererSupported() bool {
return true
}
func (r *LightRenderer) DefaultTheme() *ColorTheme {
if strings.Contains(os.Getenv("TERM"), "256") {
return Dark256
}
colors, err := exec.Command("tput", "colors").Output()
if err == nil && atoi(strings.TrimSpace(string(colors)), 16) > 16 {
return Dark256
}
return Default16
}
func (r *LightRenderer) fd() int {
return int(r.ttyin.Fd())
}
func (r *LightRenderer) initPlatform() (err error) {
r.origState, err = term.MakeRaw(r.fd())
return err
}
func (r *LightRenderer) closePlatform() {
r.ttyout.Close()
}
func openTty(ttyDefault string, mode int) (*os.File, error) {
var in *os.File
var err error
if len(ttyDefault) > 0 {
in, err = os.OpenFile(ttyDefault, mode, 0)
}
if in == nil || err != nil || ttyDefault != DefaultTtyDevice && !util.IsTty(in) {
tty := ttyname()
if len(tty) > 0 {
if in, err := os.OpenFile(tty, mode, 0); err == nil {
return in, nil
}
}
if ttyDefault != DefaultTtyDevice {
if in, err = os.OpenFile(DefaultTtyDevice, mode, 0); err == nil {
return in, nil
}
}
return nil, errors.New("failed to open " + DefaultTtyDevice)
}
return in, nil
}
func openTtyIn(ttyDefault string) (*os.File, error) {
return openTty(ttyDefault, syscall.O_RDONLY)
}
func openTtyOut(ttyDefault string) (*os.File, error) {
return openTty(ttyDefault, syscall.O_WRONLY)
}
func (r *LightRenderer) setupTerminal() {
term.MakeRaw(r.fd())
}
func (r *LightRenderer) restoreTerminal() {
term.Restore(r.fd(), r.origState)
}
func (r *LightRenderer) updateTerminalSize() {
width, height, err := term.GetSize(r.fd())
if err == nil {
r.width = width
r.height = r.maxHeightFunc(height)
} else {
r.width = getEnv("COLUMNS", defaultWidth)
r.height = r.maxHeightFunc(getEnv("LINES", defaultHeight))
}
}
// waitReadable reports whether the tty has something to read before the
// deadline. A terminal that does not recognize a query answers nothing at all,
// so the read that follows must be able to stop waiting, or fzf would wait for
// the user to press a key instead of drawing itself. The timeout is generous
// because a terminal that does answer exceeds it only when the link is slow
// enough to be unusable anyway.
func (r *LightRenderer) waitReadable(timeout time.Duration) bool {
fd := r.fd()
deadline := time.Now().Add(timeout)
for {
remaining := time.Until(deadline)
if remaining <= 0 {
return false
}
var rfds unix.FdSet
if fd >= len(rfds.Bits)*unix.NFDBITS {
return false
}
rfds.Set(fd)
// Recomputed each time round: Linux select rewrites the timeout with
// the time left, other systems leave it alone
tv := unix.NsecToTimeval(int64(remaining))
n, err := unix.Select(fd+1, &rfds, nil, nil, &tv)
if err == syscall.EINTR {
continue
}
if err != nil {
// Nothing was confirmed readable, and the read that follows
// reports the failure if the fd is really broken
return false
}
return n > 0
}
}
// queryTerminal sends every query in a single write and reads until the last
// one is answered. Returns the submatches of each reply, nil for a query the
// terminal ignored. Replies are cut out of what we read as they are recognized,
// so whatever is left over is input the user typed during the round trip.
func (r *LightRenderer) queryTerminal(queries []termQuery) [][][]byte {
for _, query := range queries {
r.csi(query.seq)
}
r.flush()
replies := make([][][]byte, len(queries))
buffer := []byte{}
for tries := range offsetPollTries {
// Only the first read blocks, so that is the one to put a bound on
if tries == 0 && !r.waitReadable(queryTimeout) {
return replies
}
var err error
buffer, _, err = r.getBytesInternal(false, buffer, tries > 0)
if err != nil {
return replies
}
for idx, query := range queries {
if replies[idx] != nil {
continue
}
loc := query.reply.FindSubmatchIndex(buffer)
if loc == nil {
continue
}
groups := make([][]byte, len(loc)/2)
for group := range groups {
if loc[group*2] >= 0 {
groups[group] = buffer[loc[group*2]:loc[group*2+1]]
}
}
replies[idx] = groups
// Capping the prefix makes append copy, leaving groups valid
buffer = append(buffer[:loc[0]:loc[0]], buffer[loc[1]:]...)
}
if replies[len(queries)-1] != nil {
break
}
}
r.buffer = append(r.buffer, buffer...)
return replies
}
func (r *LightRenderer) queryStartup() (row int, col int, pasteWasSet *bool) {
replies := r.queryTerminal(startupQueries)
if paste := replies[0]; paste != nil && paste[1][0] != '0' {
// 1 = set, 3 = permanently set
set := paste[1][0] == '1' || paste[1][0] == '3'
pasteWasSet = &set
}
row, col = parseOffset(replies[1])
return
}
func parseOffset(reply [][]byte) (row int, col int) {
if reply == nil {
return -1, -1
}
return atoi(string(reply[1]), 0) - 1, atoi(string(reply[2]), 0) - 1
}
func (r *LightRenderer) findOffset() (row int, col int) {
return parseOffset(r.queryTerminal(startupQueries[1:])[0])
}
func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {
fd := r.fd()
getter := func() (int, getCharResult) {
b := make([]byte, 1)
util.SetNonblock(r.ttyin, nonblock)
_, err := util.Read(fd, b)
if err != nil {
return 0, getCharError
}
return int(b[0]), getCharSuccess
}
if nonblock || !cancellable {
return getter()
}
rpipe, wpipe, err := os.Pipe()
if err != nil {
// Fallback to blocking read without cancellation
return getter()
}
r.setCancel(func() {
wpipe.Write([]byte{0})
})
defer func() {
r.setCancel(nil)
rpipe.Close()
wpipe.Close()
}()
cancelFd := int(rpipe.Fd())
for range maxSelectTries {
var rfds unix.FdSet
limit := len(rfds.Bits) * unix.NFDBITS
if fd >= limit || cancelFd >= limit {
return getter()
}
rfds.Set(fd)
rfds.Set(cancelFd)
_, err := unix.Select(max(fd, cancelFd)+1, &rfds, nil, nil, nil)
if err != nil {
if err == syscall.EINTR {
continue
}
return 0, getCharError
}
if rfds.IsSet(cancelFd) {
return 0, getCharCancelled
}
if rfds.IsSet(fd) {
return getter()
}
}
return 0, getCharError
}
func (r *LightRenderer) Size() TermSize {
ws, err := unix.IoctlGetWinsize(int(r.ttyin.Fd()), unix.TIOCGWINSZ)
if err != nil {
return TermSize{}
}
return TermSize{int(ws.Row), int(ws.Col), int(ws.Xpixel), int(ws.Ypixel)}
}