From 97c048083919cc6ff47fc3aeaa103806c5486416 Mon Sep 17 00:00:00 2001 From: Garrett D'Amore Date: Sat, 15 May 2021 11:19:50 -0700 Subject: [PATCH] fixes #452 Lost a key event once when exiting or suspending in v2.2.1. fixes #449 Lost keyboard input after suspend on Windows 10 PowerShell fixes #148 Make tcell usable with any io.Reader and io.Writer This introduces a new Tty interface so that applications can supply their own implementation. This should facilitate work for applications that wish to provide e.g. a webasm version of the terminal, or that need to use different kinds of file plumbing. --- nonblock_bsd.go | 29 +++------ nonblock_stub.go | 21 ------ nonblock_unix.go | 33 +++------- tscreen.go | 104 +++++++++++++++++++++++++++--- tscreen_stub.go | 18 ------ tscreen_unix.go | 112 ++------------------------------ tty.go | 56 ++++++++++++++++ tty_unix.go | 163 +++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 339 insertions(+), 197 deletions(-) delete mode 100644 nonblock_stub.go create mode 100644 tty.go create mode 100644 tty_unix.go diff --git a/nonblock_bsd.go b/nonblock_bsd.go index df8fc054..ffe5caf8 100644 --- a/nonblock_bsd.go +++ b/nonblock_bsd.go @@ -17,7 +17,6 @@ package tcell import ( - "os" "syscall" "golang.org/x/sys/unix" @@ -25,26 +24,18 @@ import ( // BSD systems use TIOC style ioctls. -// nonBlocking changes VMIN to 0, and VTIME to 1. This basically ensures that -// we can wake up the input loop. We only want to do this if we are going to interrupt -// that loop. Normally we use VMIN 1 and VTIME 0, which ensures we pick up bytes when -// they come but don't spin burning cycles. -func (t *tScreen) nonBlocking(on bool) { - fd := int(os.Stdin.Fd()) +// tcSetBufParams is used by the tty driver on UNIX systems to configure the +// buffering parameters (minimum character count and minimum wait time in msec.) +func tcSetBufParams(fd int, vMin uint8, vTime uint8) error { + _ = syscall.SetNonblock(fd, true) tio, err := unix.IoctlGetTermios(fd, unix.TIOCGETA) if err != nil { - return + return err } - if on { - tio.Cc[unix.VMIN] = 0 - tio.Cc[unix.VTIME] = 0 - } else { - // block for any output - tio.Cc[unix.VTIME] = 0 - tio.Cc[unix.VMIN] = 1 + tio.Cc[unix.VMIN] = vMin + tio.Cc[unix.VTIME] = vTime + if err = unix.IoctlSetTermios(fd, unix.TIOCSETA, tio); err != nil { + return err } - - _ = syscall.SetNonblock(fd, on) - // We want to set this *right now*. - _ = unix.IoctlSetTermios(fd, unix.TIOCSETA, tio) + return nil } diff --git a/nonblock_stub.go b/nonblock_stub.go deleted file mode 100644 index 35758c33..00000000 --- a/nonblock_stub.go +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright 2021 The TCell Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use file except in compliance with the License. -// You may obtain a copy of the license at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// +build plan9 windows js - -package tcell - -func (t *tScreen) nonBlocking(on bool) error { - return nil -} \ No newline at end of file diff --git a/nonblock_unix.go b/nonblock_unix.go index c4c374cc..768128d7 100644 --- a/nonblock_unix.go +++ b/nonblock_unix.go @@ -17,36 +17,23 @@ package tcell import ( - "os" "syscall" "golang.org/x/sys/unix" ) -// NB: We might someday wish to move Windows to this model. However, -// that would probably mean sacrificing some of the richer key reporting -// that we can obtain with the console API present on Windows. - -// nonBlocking changes VMIN to 0, and VTIME to 1. This basically ensures that -// we can wake up the input loop. We only want to do this if we are going to interrupt -// that loop. Normally we use VMIN 1 and VTIME 0, which ensures we pick up bytes when -// they come but don't spin burning cycles. -func (t *tScreen) nonBlocking(on bool) { - fd := int(os.Stdin.Fd()) +// tcSetBufParams is used by the tty driver on UNIX systems to configure the +// buffering parameters (minimum character count and minimum wait time in msec.) +func tcSetBufParams(fd int, vMin uint8, vTime uint8) error { + _ = syscall.SetNonblock(fd, true) tio, err := unix.IoctlGetTermios(fd, unix.TCGETS) if err != nil { - return + return err } - if on { - tio.Cc[unix.VMIN] = 0 - tio.Cc[unix.VTIME] = 0 - } else { - // block for any output - tio.Cc[unix.VTIME] = 0 - tio.Cc[unix.VMIN] = 1 + tio.Cc[unix.VMIN] = vMin + tio.Cc[unix.VTIME] = vTime + if err = unix.IoctlSetTermios(fd, unix.TCSETS, tio); err != nil { + return err } - - _ = syscall.SetNonblock(fd, on) - // We want to set this *right now*. - _ = unix.IoctlSetTermios(fd, unix.TCSETS, tio) + return nil } diff --git a/tscreen.go b/tscreen.go index 3ee78b30..4e3b1c07 100644 --- a/tscreen.go +++ b/tscreen.go @@ -16,6 +16,7 @@ package tcell import ( "bytes" + "errors" "io" "os" "strconv" @@ -42,6 +43,14 @@ import ( // $COLUMNS environment variables can be set to the actual window size, // otherwise defaults taken from the terminal database are used. func NewTerminfoScreen() (Screen, error) { + return NewTerminfoScreenFromTty(nil) +} + +// NewTerminfoScreenFromTty returns a Screen using a custom Tty implementation. +// If the passed in tty is nil, then a reasonable default (typically /dev/tty) +// is presumed, at least on UNIX hosts. (Windows hosts will typically fail this +// call altogether.) +func NewTerminfoScreenFromTty(tty Tty) (Screen, error) { ti, e := terminfo.LookupTerminfo(os.Getenv("TERM")) if e != nil { ti, e = loadDynamicTerminfo(os.Getenv("TERM")) @@ -50,7 +59,7 @@ func NewTerminfoScreen() (Screen, error) { } terminfo.AddTerminfo(ti) } - t := &tScreen{ti: ti} + t := &tScreen{ti: ti, tty: tty} t.keyexist = make(map[Key]bool) t.keycodes = make(map[string]*tKeyCode) @@ -77,12 +86,11 @@ type tKeyCode struct { // tScreen represents a screen backed by a terminfo implementation. type tScreen struct { ti *terminfo.Terminfo + tty Tty h int w int fini bool cells CellBuffer - in *os.File - out *os.File buffering bool // true if we are collecting writes to buf instead of sending directly to out buf bytes.Buffer curstyle Style @@ -731,7 +739,7 @@ func (t *tScreen) writeString(s string) { if t.buffering { _, _ = io.WriteString(&t.buf, s) } else { - _, _ = io.WriteString(t.out, s) + _, _ = io.WriteString(t.tty, s) } } @@ -739,7 +747,7 @@ func (t *tScreen) TPuts(s string) { if t.buffering { t.ti.TPuts(&t.buf, s) } else { - t.ti.TPuts(t.out, s) + t.ti.TPuts(t.tty, s) } } @@ -807,7 +815,7 @@ func (t *tScreen) draw() { // restore the cursor t.showCursor() - _, _ = t.buf.WriteTo(t.out) + _, _ = t.buf.WriteTo(t.tty) } func (t *tScreen) EnableMouse(flags ...MouseFlags) { @@ -885,7 +893,7 @@ func (t *tScreen) Size() (int, int) { } func (t *tScreen) resize() { - if w, h, e := t.getWinSize(); e == nil { + if w, h, e := t.tty.WindowSize(); e == nil { if w != t.w || h != t.h { t.cx = -1 t.cy = -1 @@ -1493,7 +1501,7 @@ func (t *tScreen) inputLoop(stopQ chan struct{}) { default: } chunk := make([]byte, 128) - n, e := t.in.Read(chunk) + n, e := t.tty.Read(chunk) switch e { case nil: default: @@ -1575,7 +1583,6 @@ func (t *tScreen) HasKey(k Key) bool { func (t *tScreen) Resize(int, int, int, int) {} - func (t *tScreen) Suspend() error { t.disengage() return nil @@ -1584,3 +1591,82 @@ func (t *tScreen) Suspend() error { func (t *tScreen) Resume() error { return t.engage() } + +// engage is used to place the terminal in raw mode and establish screen size, etc. +// Thing of this is as tcell "engaging" the clutch, as it's going to be driving the +// terminal interface. +func (t *tScreen) engage() error { + t.Lock() + defer t.Unlock() + if t.tty == nil { + return ErrNoScreen + } + if t.stopQ != nil { + return errors.New("already engaged") + } + if err := t.tty.Start(); err != nil { + return err + } + if w, h, err := t.tty.WindowSize(); err == nil && w != 0 && h != 0 { + t.cells.Resize(w, h) + } + stopQ := make(chan struct{}) + t.stopQ = stopQ + t.enableMouse(t.mouseFlags) + t.enablePasting(t.pasteEnabled) + + ti := t.ti + t.TPuts(ti.EnterCA) + t.TPuts(ti.EnterKeypad) + t.TPuts(ti.HideCursor) + t.TPuts(ti.EnableAcs) + t.TPuts(ti.Clear) + + t.wg.Add(2) + go t.inputLoop(stopQ) + go t.mainLoop(stopQ) + return nil +} + +// disengage is used to release the terminal back to support from the caller. +// Think of this as tcell disengaging the clutch, so that another application +// can take over the terminal interface. This restores the TTY mode that was +// present when the application was first started. +func (t *tScreen) disengage() { + + t.Lock() + stopQ := t.stopQ + t.stopQ = nil + close(stopQ) + _ = t.tty.Drain() + t.Unlock() + + // wait for everything to shut down + t.wg.Wait() + + // shutdown the screen and disable special modes (e.g. mouse and bracketed paste) + ti := t.ti + t.cells.Resize(0, 0) + t.TPuts(ti.ShowCursor) + t.TPuts(ti.AttrOff) + t.TPuts(ti.Clear) + t.TPuts(ti.ExitCA) + t.TPuts(ti.ExitKeypad) + t.enableMouse(0) + t.enablePasting(false) + + _ = t.tty.Stop() +} + +// Beep emits a beep to the terminal. +func (t *tScreen) Beep() error { + t.writeString(string(byte(7))) + return nil +} + +// finalize is used to at application shutdown, and restores the terminal +// to it's initial state. It should not be called more than once. +func (t *tScreen) finalize() { + t.disengage() + _ = t.tty.Close() +} diff --git a/tscreen_stub.go b/tscreen_stub.go index c88278be..1611cb55 100644 --- a/tscreen_stub.go +++ b/tscreen_stub.go @@ -20,24 +20,6 @@ package tcell // that would probably mean sacrificing some of the richer key reporting // that we can obtain with the console API present on Windows. -func (t *tScreen) engage() error { - return ErrNoScreen -} - -func (t *tScreen) disengage() { -} - func (t *tScreen) initialize() error { return ErrNoScreen } - -func (t *tScreen) finalize() { -} - -func (t *tScreen) getWinSize() (int, int, error) { - return 0, 0, ErrNoScreen -} - -func (t *tScreen) Beep() error { - return ErrNoScreen -} diff --git a/tscreen_unix.go b/tscreen_unix.go index 725785c3..1eb7504a 100644 --- a/tscreen_unix.go +++ b/tscreen_unix.go @@ -16,118 +16,16 @@ package tcell -import ( - "errors" - "os" - "os/signal" - "syscall" - - "golang.org/x/term" -) - -// engage is used to place the terminal in raw mode and establish screen size, etc. -// Thing of this is as tcell "engaging" the clutch, as it's going to be driving the -// terminal interface. -func (t *tScreen) engage() error { - t.Lock() - defer t.Unlock() - if t.stopQ != nil { - return errors.New("already engaged") - } - if _, err := term.MakeRaw(int(t.in.Fd())); err != nil { - return err - } - if w, h, err := term.GetSize(int(t.in.Fd())); err == nil && w != 0 && h != 0 { - t.cells.Resize(w, h) - } - stopQ := make(chan struct{}) - t.stopQ = stopQ - t.nonBlocking(false) - t.enableMouse(t.mouseFlags) - t.enablePasting(t.pasteEnabled) - signal.Notify(t.sigwinch, syscall.SIGWINCH) - - ti := t.ti - t.TPuts(ti.EnterCA) - t.TPuts(ti.EnterKeypad) - t.TPuts(ti.HideCursor) - t.TPuts(ti.EnableAcs) - t.TPuts(ti.Clear) - - t.wg.Add(2) - go t.inputLoop(stopQ) - go t.mainLoop(stopQ) - return nil -} - -// disengage is used to release the terminal back to support from the caller. -// Think of this as tcell disengaging the clutch, so that another application -// can take over the terminal interface. This restores the TTY mode that was -// present when the application was first started. -func (t *tScreen) disengage() { - - t.Lock() - t.nonBlocking(true) - stopQ := t.stopQ - t.stopQ = nil - close(stopQ) - t.Unlock() - - // wait for everything to shut down - t.wg.Wait() - - signal.Stop(t.sigwinch) - - // put back normal blocking mode - t.nonBlocking(false) - - // shutdown the screen and disable special modes (e.g. mouse and bracketed paste) - ti := t.ti - t.cells.Resize(0, 0) - t.TPuts(ti.ShowCursor) - t.TPuts(ti.AttrOff) - t.TPuts(ti.Clear) - t.TPuts(ti.ExitCA) - t.TPuts(ti.ExitKeypad) - t.enableMouse(0) - t.enablePasting(false) - - // restore the termios that we were started with - _ = term.Restore(int(t.in.Fd()), t.saved) - -} - // initialize is used at application startup, and sets up the initial values // including file descriptors used for terminals and saving the initial state // so that it can be restored when the application terminates. func (t *tScreen) initialize() error { var err error - t.out = os.Stdout - if t.in, err = os.Open("/dev/tty"); err != nil { - return err + if t.tty == nil { + t.tty, err = NewDevTty() + if err != nil { + return err + } } - - t.saved, err = term.GetState(int(t.in.Fd())) - if err == nil { - return nil - } - return nil -} - -// finalize is used to at application shutdown, and restores the terminal -// to it's initial state. It should not be called more than once. -func (t *tScreen) finalize() { - - t.disengage() -} - -// getWinSize is called to obtain the terminal dimensions. -func (t *tScreen) getWinSize() (int, int, error) { - return term.GetSize(int(t.in.Fd())) -} - -// Beep emits a beep to the terminal. -func (t *tScreen) Beep() error { - t.writeString(string(byte(7))) return nil } diff --git a/tty.go b/tty.go new file mode 100644 index 00000000..567b0418 --- /dev/null +++ b/tty.go @@ -0,0 +1,56 @@ +// Copyright 2021 The TCell Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use file except in compliance with the License. +// You may obtain a copy of the license at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tcell + +import "io" + +// Tty is an abstraction of a tty (traditionally "teletype"). This allows applications to +// provide for alternate backends, as there are situations where the traditional /dev/tty +// does not work, or where more flexible handling is required. This interface is for use +// with the terminfo-style based API. It extends the io.ReadWriter API. It is reasonable +// that the implementation might choose to use different underlying files for the Reader +// and Writer sides of this API, as part of it's internal implementation. +type Tty interface { + // Start is used to activate the Tty for use. Upon return the terminal should be + // in raw mode, non-blocking, etc. The implementation should take care of saving + // any state that is required so that it may be restored when Stop is called. + Start() error + + // Stop is used to stop using this Tty instance. This may be a suspend, so that other + // terminal based applications can run in the foreground. Implementations should + // restore any state collected at Start(), and return to ordinary blocking mode, etc. + // Drain is called first to drain the input. Once this is called, no more Read + // or Write calls will be made until Start is called again. + Stop() error + + // Drain is called before Stop, and ensures that the reader will wake up appropriately + // if it was blocked. This workaround is required for /dev/tty on certain UNIX systems + // to ensure that Read() does not block forever. This typically arranges for the tty driver + // to send data immediately (e.g. VMIN and VTIME both set zero) and sets a deadline on input. + // Implementations may reasonably make this a no-op. There will still be control sequences + // emitted between the time this is called, and when Stop is called. + Drain() error + + // NotifyResize is used register a callback when the tty thinks the dimensions have + // changed. The standard UNIX implementation links this to a handler for SIGWINCH. + // If the supplied callback is nil, then any handler should be unregistered. + NotifyResize(cb func()) + + // WindowSize is called to determine the terminal dimensions. This might be determined + // by an ioctl or other means. + WindowSize() (width int, height int, err error) + + io.ReadWriteCloser +} \ No newline at end of file diff --git a/tty_unix.go b/tty_unix.go new file mode 100644 index 00000000..ae9c2c3c --- /dev/null +++ b/tty_unix.go @@ -0,0 +1,163 @@ +// Copyright 2021 The TCell Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use file except in compliance with the License. +// You may obtain a copy of the license at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris zos + +package tcell + +import ( + "errors" + "fmt" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "golang.org/x/term" +) + +// devTty is an implementation of the Tty API based upon /dev/tty. +type devTty struct { + fd int + f *os.File + of *os.File // the first open of /dev/tty + saved *term.State + sig chan os.Signal + cb func() + stopQ chan struct{} + wg sync.WaitGroup + l sync.Mutex +} + +func (tty *devTty) Read(b []byte) (int, error) { + return tty.f.Read(b) +} + +func (tty *devTty) Write(b []byte) (int, error) { + return tty.f.Write(b) +} + +func (tty *devTty) Close() error { + return nil +} + +func (tty *devTty) Start() error { + tty.l.Lock() + defer tty.l.Unlock() + + // We open another copy of /dev/tty. This is a workaround for unusual behavior + // observed in macOS, apparently caused when a subshell (for example) closes our + // own tty device (when it exits for example). Getting a fresh new one seems to + // resolve the problem. (We believe this is a bug in the macOS tty driver that + // fails to account for dup() references to the same file before applying close() + // related behaviors to the tty.) We're also holding the original copy we opened + // since closing that might have deleterious effects as well. The upshot is that + // we will have up to two separate file handles open on /dev/tty. (Note that when + // using stdin/stdout instead of /dev/tty this problem is not observed.) + var err error + if tty.f, err = os.OpenFile("/dev/tty", os.O_RDWR, 0); err != nil { + return err + } + tty.fd = int(tty.of.Fd()) + + if !term.IsTerminal(tty.fd) { + return errors.New("device is not a terminal") + } + + _ = tty.f.SetReadDeadline(time.Time{}) + saved, err := term.MakeRaw(tty.fd) // also sets vMin and vTime + if err != nil { + return err + } + tty.saved = saved + + tty.stopQ = make(chan struct{}) + tty.wg.Add(1) + go func(stopQ chan struct{}) { + defer tty.wg.Done() + for { + select { + case <-tty.sig: + tty.l.Lock() + cb := tty.cb + tty.l.Unlock() + cb() + case <-stopQ: + return + } + } + }(tty.stopQ) + + signal.Notify(tty.sig, syscall.SIGWINCH) + return nil +} + +func (tty *devTty) Drain() error { + _ = tty.f.SetReadDeadline(time.Now()) + if err := tcSetBufParams(tty.fd, 0, 0); err != nil { + return err + } + return nil +} + +func (tty *devTty) Stop() error { + tty.l.Lock() + if err := term.Restore(tty.fd, tty.saved); err != nil { + tty.l.Unlock() + return err + } + _ = tty.f.SetReadDeadline(time.Now()) + + signal.Stop(tty.sig) + close(tty.stopQ) + tty.l.Unlock() + + tty.wg.Wait() + + // close our tty device -- we'll get another one if we Start again later. + _ = tty.f.Close() + + return nil +} + +func (tty *devTty) WindowSize() (int, int, error) { + return term.GetSize(tty.fd) +} + +func (tty *devTty) NotifyResize(cb func()) { + tty.l.Lock() + tty.cb = cb + tty.l.Unlock() +} + +func NewDevTty() (Tty, error) { + tty := &devTty{ + sig: make(chan os.Signal), + } + var err error + if tty.of, err = os.OpenFile("/dev/tty", os.O_RDWR, 0); err != nil { + return nil, err + } + tty.fd = int(tty.of.Fd()) + if !term.IsTerminal(tty.fd) { + _ = tty.f.Close() + return nil, errors.New("not a terminal") + } + if tty.saved, err = term.GetState(tty.fd); err != nil { + _ = tty.f.Close() + return nil, fmt.Errorf("failed to get state: %w", err) + } + return tty, nil +}