v0.3.0 write output on its own goroutine, blocked terminal report
This commit is contained in:
7 files changed
+288
-78
No files matched your search
@@ -24,7 +24,8 @@ consoles with VT processing, and browsers via xterm.js (WASM builds).
|
|||||||
|
|
||||||
Terminal (interface)
|
Terminal (interface)
|
||||||
└── termImpl
|
└── termImpl
|
||||||
├── outputBuffer diffing, ANSI generation, 128KB buffered writer
|
├── outputBuffer diffing, ANSI generation
|
||||||
|
├── writeLoop the one goroutine that writes output
|
||||||
├── inputReader escape sequence parser, event channel
|
├── inputReader escape sequence parser, event channel
|
||||||
└── Backend (interface)
|
└── Backend (interface)
|
||||||
├── unixBackend //go:build unix — termios, unix.Poll, SIGWINCH, /dev/tty
|
├── unixBackend //go:build unix — termios, unix.Poll, SIGWINCH, /dev/tty
|
||||||
@@ -55,6 +56,15 @@ passes it to `Flush`. The output buffer diffs against the previously flushed fra
|
|||||||
`Sync()` clears the screen and invalidates the front buffer, forcing a full
|
`Sync()` clears the screen and invalidates the front buffer, forcing a full
|
||||||
redraw — required after any external process writes to the terminal.
|
redraw — required after any external process writes to the terminal.
|
||||||
|
|
||||||
|
Output is written on a goroutine of its own, so no call waits on the terminal.
|
||||||
|
A terminal that stops reading (a hidden tab, a multiplexer client that went
|
||||||
|
away, flow control) holds that goroutine in its write; meanwhile `Flush` keeps
|
||||||
|
only the newest frame, drawn once the write returns, and control sequences
|
||||||
|
(cursor, mouse, paste, `Clear`, `Sync`) queue behind it in call order.
|
||||||
|
`Blocked()` reports how long the write in flight has waited, so a caller can
|
||||||
|
stop composing frames nobody will see. `Fini` waits up to two seconds for the
|
||||||
|
restoring sequences, then restores the termios without them.
|
||||||
|
|
||||||
Auto-wrap and, where the terminal follows ECMA-48 mode 8 (VTE), its own bidi
|
Auto-wrap and, where the terminal follows ECMA-48 mode 8 (VTE), its own bidi
|
||||||
reordering are off during the session: the bottom-right cell is writable
|
reordering are off during the session: the bottom-right cell is writable
|
||||||
without scroll side effects, and right-to-left text stays in its cells.
|
without scroll side effects, and right-to-left text stays in its cells.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package terminal
|
package terminal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bytes"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pre-allocated ANSI sequence fragments (avoid allocations during render)
|
// Pre-allocated ANSI sequence fragments (avoid allocations during render)
|
||||||
@@ -49,7 +49,7 @@ var (
|
|||||||
|
|
||||||
// writeInt writes an integer without allocation
|
// writeInt writes an integer without allocation
|
||||||
// Optimized for terminal values (0-255 common, 0-999 typical max)
|
// Optimized for terminal values (0-255 common, 0-999 typical max)
|
||||||
func writeInt(w *bufio.Writer, n int) {
|
func writeInt(w *bytes.Buffer, n int) {
|
||||||
if n < 0 {
|
if n < 0 {
|
||||||
n = 0
|
n = 0
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ func writeInt(w *bufio.Writer, n int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// writeCursorPos writes cursor positioning sequence (0-indexed input)
|
// writeCursorPos writes cursor positioning sequence (0-indexed input)
|
||||||
func writeCursorPos(w *bufio.Writer, x, y int) {
|
func writeCursorPos(w *bytes.Buffer, x, y int) {
|
||||||
w.Write(csiCursorPos)
|
w.Write(csiCursorPos)
|
||||||
writeInt(w, y+1)
|
writeInt(w, y+1)
|
||||||
w.WriteByte(';')
|
w.WriteByte(';')
|
||||||
@@ -88,7 +88,7 @@ func writeCursorPos(w *bufio.Writer, x, y int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// writeCursorForward writes cursor forward N positions
|
// writeCursorForward writes cursor forward N positions
|
||||||
func writeCursorForward(w *bufio.Writer, n int) {
|
func writeCursorForward(w *bytes.Buffer, n int) {
|
||||||
if n <= 0 {
|
if n <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,8 +39,11 @@
|
|||||||
//
|
//
|
||||||
// # Performance
|
// # Performance
|
||||||
//
|
//
|
||||||
// Output uses 128KB buffered writer with cell-level diffing. Only changed cells
|
// Output uses cell-level diffing. Only changed cells generate ANSI sequences.
|
||||||
// generate ANSI sequences. Style attributes are coalesced to minimize SGR calls.
|
// Style attributes are coalesced to minimize SGR calls. One goroutine writes
|
||||||
|
// what is rendered, so a terminal that stops reading never holds the caller:
|
||||||
|
// Flush keeps the newest frame until the write in flight returns, and Blocked
|
||||||
|
// reports how long it has waited.
|
||||||
// Input parsing is zero-allocation for common cases.
|
// Input parsing is zero-allocation for common cases.
|
||||||
//
|
//
|
||||||
// This package bypasses terminfo/termcap entirely, emitting direct ANSI sequences.
|
// This package bypasses terminfo/termcap entirely, emitting direct ANSI sequences.
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
package terminal
|
package terminal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bytes"
|
||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/lixenwraith/color"
|
"github.com/lixenwraith/color"
|
||||||
)
|
)
|
||||||
|
|
||||||
// outputBuffer manages double-buffered terminal output with diffing
|
// outputBuffer renders double-buffered terminal output with diffing. It only
|
||||||
|
// encodes: what an operation renders collects in buf, and termImpl hands it to
|
||||||
|
// the goroutine that writes it.
|
||||||
type outputBuffer struct {
|
type outputBuffer struct {
|
||||||
front []Cell
|
front []Cell
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
colorMode ColorMode
|
colorMode ColorMode
|
||||||
writer *bufio.Writer
|
buf bytes.Buffer
|
||||||
|
|
||||||
cursorX int
|
cursorX int
|
||||||
cursorY int
|
cursorY int
|
||||||
@@ -27,7 +29,7 @@ type outputBuffer struct {
|
|||||||
lastValid bool
|
lastValid bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// writerAdapter adapts Backend to io.Writer for bufio
|
// writerAdapter adapts Backend to io.Writer
|
||||||
type writerAdapter struct {
|
type writerAdapter struct {
|
||||||
b Backend
|
b Backend
|
||||||
}
|
}
|
||||||
@@ -40,13 +42,8 @@ func (wa writerAdapter) Write(p []byte) (int, error) {
|
|||||||
return len(p), nil
|
return len(p), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newOutputBuffer(backend Backend, colorMode ColorMode) *outputBuffer {
|
func newOutputBuffer(colorMode ColorMode) *outputBuffer {
|
||||||
// Use 128KB buffer for minimal calls to backend
|
return &outputBuffer{colorMode: colorMode}
|
||||||
adapter := writerAdapter{b: backend}
|
|
||||||
return &outputBuffer{
|
|
||||||
writer: bufio.NewWriterSize(adapter, 131072),
|
|
||||||
colorMode: colorMode,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *outputBuffer) resize(width, height int) {
|
func (o *outputBuffer) resize(width, height int) {
|
||||||
@@ -74,7 +71,7 @@ func cellEqual(a, b Cell) bool {
|
|||||||
a.Attrs == b.Attrs
|
a.Attrs == b.Attrs
|
||||||
}
|
}
|
||||||
|
|
||||||
// flush writes the back buffer to terminal, diffing against front buffer
|
// flush renders the back buffer into buf, diffing against front buffer
|
||||||
func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
||||||
if width != o.width || height != o.height {
|
if width != o.width || height != o.height {
|
||||||
o.resize(width, height)
|
o.resize(width, height)
|
||||||
@@ -85,7 +82,7 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w := o.writer
|
w := &o.buf
|
||||||
|
|
||||||
for y := 0; y < height; y++ {
|
for y := 0; y < height; y++ {
|
||||||
rowStart := y * width
|
rowStart := y * width
|
||||||
@@ -178,7 +175,6 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
|||||||
|
|
||||||
w.Write(csiSGR0)
|
w.Write(csiSGR0)
|
||||||
o.lastValid = false
|
o.lastValid = false
|
||||||
w.Flush()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// oneColumn is r as a cell draws it: one glyph in one column, where the
|
// oneColumn is r as a cell draws it: one glyph in one column, where the
|
||||||
@@ -292,7 +288,7 @@ func digitCount(n int) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// moveCursorTo positions cursor using most efficient method
|
// moveCursorTo positions cursor using most efficient method
|
||||||
func (o *outputBuffer) moveCursorTo(w *bufio.Writer, x, y int) {
|
func (o *outputBuffer) moveCursorTo(w *bytes.Buffer, x, y int) {
|
||||||
if o.cursorValid && o.cursorX == x && o.cursorY == y {
|
if o.cursorValid && o.cursorX == x && o.cursorY == y {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -319,7 +315,7 @@ func (o *outputBuffer) moveCursorTo(w *bufio.Writer, x, y int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// writeStyleCoalesced emits a single combined SGR sequence when style changes
|
// writeStyleCoalesced emits a single combined SGR sequence when style changes
|
||||||
func (o *outputBuffer) writeStyleCoalesced(w *bufio.Writer, fg, bg color.RGB, attr Attr) {
|
func (o *outputBuffer) writeStyleCoalesced(w *bytes.Buffer, fg, bg color.RGB, attr Attr) {
|
||||||
if o.colorMode == ColorModeNone {
|
if o.colorMode == ColorModeNone {
|
||||||
fg, bg, attr = color.RGB{}, color.RGB{}, attr&AttrStyle
|
fg, bg, attr = color.RGB{}, color.RGB{}, attr&AttrStyle
|
||||||
}
|
}
|
||||||
@@ -372,7 +368,7 @@ var attrCodes = [...]struct {
|
|||||||
// writeColor writes one color's SGR parameters, after a ';' when sep, for
|
// writeColor writes one color's SGR parameters, after a ';' when sep, for
|
||||||
// base 30 (foreground) or 40 (background): the terminal's own, a palette
|
// base 30 (foreground) or 40 (background): the terminal's own, a palette
|
||||||
// index, or RGB, as the color mode allows. ColorModeNone writes nothing.
|
// index, or RGB, as the color mode allows. ColorModeNone writes nothing.
|
||||||
func (o *outputBuffer) writeColor(w *bufio.Writer, c color.RGB, palette, deflt bool, base int, sep bool) {
|
func (o *outputBuffer) writeColor(w *bytes.Buffer, c color.RGB, palette, deflt bool, base int, sep bool) {
|
||||||
if o.colorMode == ColorModeNone {
|
if o.colorMode == ColorModeNone {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -418,10 +414,10 @@ func (o *outputBuffer) forceFullRedraw() {
|
|||||||
o.cursorValid = false
|
o.cursorValid = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// clear writes a clear screen with specified background, attr's
|
// clear renders a clear screen with specified background, attr's
|
||||||
// AttrBgDefault or AttrBg256 saying how to read it
|
// AttrBgDefault or AttrBg256 saying how to read it
|
||||||
func (o *outputBuffer) clear(bg color.RGB, attr Attr) {
|
func (o *outputBuffer) clear(bg color.RGB, attr Attr) {
|
||||||
w := o.writer
|
w := &o.buf
|
||||||
w.Write(csiSGR0)
|
w.Write(csiSGR0)
|
||||||
w.Write(csi)
|
w.Write(csi)
|
||||||
o.writeColor(w, bg, attr&AttrBg256 != 0, attr&AttrBgDefault != 0, 40, false)
|
o.writeColor(w, bg, attr&AttrBg256 != 0, attr&AttrBgDefault != 0, 40, false)
|
||||||
@@ -430,7 +426,6 @@ func (o *outputBuffer) clear(bg color.RGB, attr Attr) {
|
|||||||
|
|
||||||
o.lastValid = false
|
o.lastValid = false
|
||||||
o.cursorValid = false
|
o.cursorValid = false
|
||||||
w.Flush()
|
|
||||||
|
|
||||||
for i := range o.front {
|
for i := range o.front {
|
||||||
o.front[i] = Cell{Rune: ' ', Bg: bg, Attrs: attr & AttrBgColor}
|
o.front[i] = Cell{Rune: ' ', Bg: bg, Attrs: attr & AttrBgColor}
|
||||||
|
|||||||
+115
-38
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/lixenwraith/color"
|
"github.com/lixenwraith/color"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
@@ -61,10 +62,16 @@ type Terminal interface {
|
|||||||
// ColorMode returns detected color capability
|
// ColorMode returns detected color capability
|
||||||
ColorMode() ColorMode
|
ColorMode() ColorMode
|
||||||
|
|
||||||
// Flush writes cell buffer to terminal
|
// Flush hands a frame to the terminal; cells are row-major: cells[y*width + x].
|
||||||
// Cells are row-major: cells[y*width + x]
|
// It never waits on the terminal: while the last write is still out, the frame
|
||||||
|
// is held and drawn once that write returns, a newer one replacing it
|
||||||
Flush(cells []Cell, width, height int)
|
Flush(cells []Cell, width, height int)
|
||||||
|
|
||||||
|
// Blocked reports how long the terminal has held the write in flight, zero
|
||||||
|
// while output drains. A terminal that stops reading (a hidden tab, a client
|
||||||
|
// that went away, flow control) shows here instead of as a caller that waits
|
||||||
|
Blocked() time.Duration
|
||||||
|
|
||||||
// Clear fills screen with specified background color
|
// Clear fills screen with specified background color
|
||||||
Clear(bg color.RGB)
|
Clear(bg color.RGB)
|
||||||
|
|
||||||
@@ -114,8 +121,28 @@ type termImpl struct {
|
|||||||
finalized bool
|
finalized bool
|
||||||
mouseMode MouseMode
|
mouseMode MouseMode
|
||||||
paste bool
|
paste bool
|
||||||
|
|
||||||
|
// Output reaches the terminal on writeLoop's goroutine, so a terminal that
|
||||||
|
// stops reading holds that goroutine rather than whoever draws. queued is what
|
||||||
|
// was rendered and not yet taken; a frame Flush is handed while a write is out
|
||||||
|
// waits in held, newest first, until it returns. Both are guarded by mu.
|
||||||
|
queued []byte
|
||||||
|
writing []byte // owned by writeLoop
|
||||||
|
held []Cell
|
||||||
|
heldW int
|
||||||
|
heldH int
|
||||||
|
holding bool
|
||||||
|
writeSince atomic.Int64 // unix nanoseconds the write out began; zero when none is
|
||||||
|
wake chan struct{}
|
||||||
|
quit chan struct{}
|
||||||
|
writerDone chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// finiDrainTimeout bounds how long Fini waits for the terminal to take what is
|
||||||
|
// queued, the restoring sequences last: a terminal that stopped reading would
|
||||||
|
// otherwise hold the process at exit
|
||||||
|
const finiDrainTimeout = 2 * time.Second
|
||||||
|
|
||||||
// New creates a new Terminal instance. Without a mode it uses
|
// New creates a new Terminal instance. Without a mode it uses
|
||||||
// DetectColorMode's, or ColorModeNone when NO_COLOR is set; an explicit mode
|
// DetectColorMode's, or ColorModeNone when NO_COLOR is set; an explicit mode
|
||||||
// overrides both, as a --color=always or never would
|
// overrides both, as a --color=always or never would
|
||||||
@@ -129,15 +156,19 @@ func New(colorMode ...ColorMode) Terminal {
|
|||||||
c = ColorModeNone
|
c = ColorModeNone
|
||||||
}
|
}
|
||||||
|
|
||||||
t := &termImpl{
|
return newTerm(b, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTerm(b Backend, c ColorMode) *termImpl {
|
||||||
|
return &termImpl{
|
||||||
backend: b,
|
backend: b,
|
||||||
|
output: newOutputBuffer(c),
|
||||||
syntheticCh: make(chan Event, 16),
|
syntheticCh: make(chan Event, 16),
|
||||||
resizeCh: make(chan ResizeEvent, 1),
|
resizeCh: make(chan ResizeEvent, 1),
|
||||||
|
wake: make(chan struct{}, 1),
|
||||||
|
quit: make(chan struct{}),
|
||||||
|
writerDone: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize output buffer with backend
|
|
||||||
t.output = newOutputBuffer(b, c)
|
|
||||||
return t
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init enters raw mode and sets up terminal
|
// Init enters raw mode and sets up terminal
|
||||||
@@ -178,13 +209,16 @@ func (t *termImpl) Init() error {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
go t.writeLoop()
|
||||||
|
|
||||||
// Enter alternate screen, hide cursor
|
// Enter alternate screen, hide cursor
|
||||||
t.writeRaw(csiAltScreenEnter)
|
out := &t.output.buf
|
||||||
t.writeRaw(csiCursorHide)
|
out.Write(csiAltScreenEnter)
|
||||||
|
out.Write(csiCursorHide)
|
||||||
|
|
||||||
// DISABLE AUTO-WRAP
|
// DISABLE AUTO-WRAP
|
||||||
// Prevents terminal scroll/wrap on bottom-right corner write
|
// Prevents terminal scroll/wrap on bottom-right corner write
|
||||||
t.writeRaw(csiAutoWrapOff)
|
out.Write(csiAutoWrapOff)
|
||||||
|
|
||||||
// Invisible cursor
|
// Invisible cursor
|
||||||
t.cursorVisible.Store(false)
|
t.cursorVisible.Store(false)
|
||||||
@@ -196,6 +230,7 @@ func (t *termImpl) Init() error {
|
|||||||
|
|
||||||
// Clear screen
|
// Clear screen
|
||||||
t.output.clear(color.RGB{}, AttrBgDefault)
|
t.output.clear(color.RGB{}, AttrBgDefault)
|
||||||
|
t.sendLocked()
|
||||||
|
|
||||||
// Start input reader
|
// Start input reader
|
||||||
t.input.start()
|
t.input.start()
|
||||||
@@ -207,24 +242,25 @@ func (t *termImpl) Init() error {
|
|||||||
// Fini restores terminal state
|
// Fini restores terminal state
|
||||||
func (t *termImpl) Fini() {
|
func (t *termImpl) Fini() {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
|
||||||
|
|
||||||
if !t.initialized || t.finalized {
|
if !t.initialized || t.finalized {
|
||||||
|
t.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
t.finalized = true
|
||||||
|
t.holding = false // a frame not yet drawn is not drawn over the restored screen
|
||||||
|
w := &t.output.buf
|
||||||
|
|
||||||
// Disable mouse before other cleanup
|
// Disable mouse before other cleanup
|
||||||
if t.mouseMode != MouseModeNone {
|
if t.mouseMode != MouseModeNone {
|
||||||
w := t.output.writer
|
|
||||||
w.Write(csiMouseMotionOff)
|
w.Write(csiMouseMotionOff)
|
||||||
w.Write(csiMouseDragOff)
|
w.Write(csiMouseDragOff)
|
||||||
w.Write(csiMouseClickOff)
|
w.Write(csiMouseClickOff)
|
||||||
w.Write(csiMouseSGROff)
|
w.Write(csiMouseSGROff)
|
||||||
w.Flush()
|
t.sendLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.paste {
|
if t.paste {
|
||||||
t.writeRaw(csiPasteOff)
|
w.Write(csiPasteOff)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop handlers
|
// Stop handlers
|
||||||
@@ -233,21 +269,29 @@ func (t *termImpl) Fini() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show cursor
|
// Show cursor
|
||||||
t.writeRaw(csiCursorShow)
|
w.Write(csiCursorShow)
|
||||||
|
|
||||||
// Exit alternate screen
|
// Exit alternate screen
|
||||||
t.writeRaw(csiAltScreenExit)
|
w.Write(csiAltScreenExit)
|
||||||
|
|
||||||
// Re-enable Auto-Wrap AFTER exiting alt screen to ensure the main buffer has wrap enabled
|
// Re-enable Auto-Wrap AFTER exiting alt screen to ensure the main buffer has wrap enabled
|
||||||
t.writeRaw(csiAutoWrapOn)
|
w.Write(csiAutoWrapOn)
|
||||||
|
|
||||||
// Reset attributes
|
// Reset attributes
|
||||||
t.writeRaw(csiSGR0)
|
w.Write(csiSGR0)
|
||||||
|
t.sendLocked()
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
// The restoring sequences leave before raw mode does, unless the terminal has
|
||||||
|
// stopped reading: then the termios is restored without them
|
||||||
|
close(t.quit)
|
||||||
|
select {
|
||||||
|
case <-t.writerDone:
|
||||||
|
case <-time.After(finiDrainTimeout):
|
||||||
|
}
|
||||||
|
|
||||||
// Backend cleanup
|
// Backend cleanup
|
||||||
t.backend.Fini()
|
t.backend.Fini()
|
||||||
|
|
||||||
t.finalized = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Size returns current terminal dimensions
|
// Size returns current terminal dimensions
|
||||||
@@ -265,8 +309,8 @@ func (t *termImpl) ColorMode() ColorMode {
|
|||||||
return t.output.colorMode
|
return t.output.colorMode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush writes cell buffer to terminal
|
// Flush renders cells and queues them for the terminal, or holds them while a write
|
||||||
// Holds lock for entire operation to prevent race with Clear/MoveCursor
|
// is out. Holds lock for entire operation to prevent race with Clear/MoveCursor
|
||||||
func (t *termImpl) Flush(cells []Cell, width, height int) {
|
func (t *termImpl) Flush(cells []Cell, width, height int) {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
@@ -275,6 +319,18 @@ func (t *termImpl) Flush(cells []Cell, width, height int) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if t.writeSince.Load() != 0 {
|
||||||
|
t.held = append(t.held[:0], cells[:min(len(cells), width*height)]...)
|
||||||
|
t.heldW, t.heldH, t.holding = width, height, true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A frame is a whole screen, so one rendered now supersedes any held
|
||||||
|
t.holding = false
|
||||||
|
t.renderLocked(cells, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderLocked diffs one frame into the queue. Caller MUST hold mu.
|
||||||
|
func (t *termImpl) renderLocked(cells []Cell, width, height int) {
|
||||||
// Validation against backend size; if mismatch, drop frame to prevent resize race corruption
|
// Validation against backend size; if mismatch, drop frame to prevent resize race corruption
|
||||||
currW, currH := t.backend.Size()
|
currW, currH := t.backend.Size()
|
||||||
if currW != width || currH != height {
|
if currW != width || currH != height {
|
||||||
@@ -282,6 +338,24 @@ func (t *termImpl) Flush(cells []Cell, width, height int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.output.flush(cells, width, height)
|
t.output.flush(cells, width, height)
|
||||||
|
t.sendLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseLocked renders a held frame ahead of a control sequence, so the terminal
|
||||||
|
// receives the two in the order they were asked for. Caller MUST hold mu.
|
||||||
|
func (t *termImpl) releaseLocked() {
|
||||||
|
if t.holding {
|
||||||
|
t.holding = false
|
||||||
|
t.renderLocked(t.held, t.heldW, t.heldH)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocked reports how long the write in flight has waited on the terminal
|
||||||
|
func (t *termImpl) Blocked() time.Duration {
|
||||||
|
if since := t.writeSince.Load(); since != 0 {
|
||||||
|
return time.Since(time.Unix(0, since))
|
||||||
|
}
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear fills screen with background color
|
// Clear fills screen with background color
|
||||||
@@ -293,7 +367,9 @@ func (t *termImpl) Clear(bg color.RGB) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.releaseLocked()
|
||||||
t.output.clear(bg, 0)
|
t.output.clear(bg, 0)
|
||||||
|
t.sendLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCursorVisible shows/hides cursor
|
// SetCursorVisible shows/hides cursor
|
||||||
@@ -309,13 +385,14 @@ func (t *termImpl) SetCursorVisible(visible bool) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w := t.output.writer
|
t.releaseLocked()
|
||||||
|
w := &t.output.buf
|
||||||
if visible {
|
if visible {
|
||||||
w.Write(csiCursorShow)
|
w.Write(csiCursorShow)
|
||||||
} else {
|
} else {
|
||||||
w.Write(csiCursorHide)
|
w.Write(csiCursorHide)
|
||||||
}
|
}
|
||||||
w.Flush()
|
t.sendLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveCursor positions cursor (0-indexed)
|
// MoveCursor positions cursor (0-indexed)
|
||||||
@@ -327,6 +404,7 @@ func (t *termImpl) MoveCursor(x, y int) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.releaseLocked()
|
||||||
if t.output != nil {
|
if t.output != nil {
|
||||||
t.output.invalidateCursor()
|
t.output.invalidateCursor()
|
||||||
}
|
}
|
||||||
@@ -345,10 +423,9 @@ func (t *termImpl) MoveCursor(x, y int) {
|
|||||||
y = h - 1
|
y = h - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write through buffered writer to maintain stream order
|
// Through the queue, to keep the stream's order
|
||||||
wBuf := t.output.writer
|
writeCursorPos(&t.output.buf, x, y)
|
||||||
writeCursorPos(wBuf, x, y)
|
t.sendLocked()
|
||||||
wBuf.Flush()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync forces full redraw
|
// Sync forces full redraw
|
||||||
@@ -362,8 +439,10 @@ func (t *termImpl) Sync() {
|
|||||||
|
|
||||||
// Clear terminal before full redraw
|
// Clear terminal before full redraw
|
||||||
// Diff-based rendering assumes physical terminal matches front buffer state
|
// Diff-based rendering assumes physical terminal matches front buffer state
|
||||||
|
t.releaseLocked()
|
||||||
t.output.clear(color.RGB{}, AttrBgDefault)
|
t.output.clear(color.RGB{}, AttrBgDefault)
|
||||||
t.output.forceFullRedraw()
|
t.output.forceFullRedraw()
|
||||||
|
t.sendLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
// PollEvent blocks until next input event
|
// PollEvent blocks until next input event
|
||||||
@@ -412,7 +491,8 @@ func (t *termImpl) SetMouseMode(mode MouseMode) error {
|
|||||||
oldMode := t.mouseMode
|
oldMode := t.mouseMode
|
||||||
t.mouseMode = mode
|
t.mouseMode = mode
|
||||||
|
|
||||||
w := t.output.writer
|
t.releaseLocked()
|
||||||
|
w := &t.output.buf
|
||||||
|
|
||||||
// Disable modes no longer needed (reverse order of enable)
|
// Disable modes no longer needed (reverse order of enable)
|
||||||
if oldMode&MouseModeMotion != 0 && mode&MouseModeMotion == 0 {
|
if oldMode&MouseModeMotion != 0 && mode&MouseModeMotion == 0 {
|
||||||
@@ -446,7 +526,7 @@ func (t *termImpl) SetMouseMode(mode MouseMode) error {
|
|||||||
w.Write(csiMouseMotionOn)
|
w.Write(csiMouseMotionOn)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Flush()
|
t.sendLocked()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,18 +540,15 @@ func (t *termImpl) SetPasteMode(on bool) error {
|
|||||||
}
|
}
|
||||||
t.paste = on
|
t.paste = on
|
||||||
t.input.pasteMode.Store(on)
|
t.input.pasteMode.Store(on)
|
||||||
w := t.output.writer
|
t.releaseLocked()
|
||||||
|
w := &t.output.buf
|
||||||
if on {
|
if on {
|
||||||
w.Write(csiPasteOn)
|
w.Write(csiPasteOn)
|
||||||
} else {
|
} else {
|
||||||
w.Write(csiPasteOff)
|
w.Write(csiPasteOff)
|
||||||
}
|
}
|
||||||
return w.Flush()
|
t.sendLocked()
|
||||||
}
|
return nil
|
||||||
|
|
||||||
// writeRaw writes raw bytes to output
|
|
||||||
func (t *termImpl) writeRaw(data []byte) {
|
|
||||||
t.backend.Write(data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmergencyReset attempts to restore terminal to sane state
|
// EmergencyReset attempts to restore terminal to sane state
|
||||||
|
|||||||
+82
-13
@@ -18,6 +18,7 @@ type fakeBackend struct {
|
|||||||
out bytes.Buffer
|
out bytes.Buffer
|
||||||
chunks []string
|
chunks []string
|
||||||
pause time.Duration
|
pause time.Duration
|
||||||
|
gate chan struct{} // when set, a write waits until it is closed
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeBackend) Init() error { return nil }
|
func (f *fakeBackend) Init() error { return nil }
|
||||||
@@ -26,6 +27,9 @@ func (f *fakeBackend) Size() (int, int) { return 1, 1 }
|
|||||||
func (f *fakeBackend) SetResizeHandler(func(width, height int)) {}
|
func (f *fakeBackend) SetResizeHandler(func(width, height int)) {}
|
||||||
|
|
||||||
func (f *fakeBackend) Write(p []byte) error {
|
func (f *fakeBackend) Write(p []byte) error {
|
||||||
|
if f.gate != nil {
|
||||||
|
<-f.gate
|
||||||
|
}
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
f.out.Write(p)
|
f.out.Write(p)
|
||||||
@@ -80,10 +84,9 @@ func TestColorModesWriteWhatTheTerminalReads(t *testing.T) {
|
|||||||
{ColorModeTrueColor, []Cell{{Rune: 'x', Fg: color.RGB{R: 1, G: 2, B: 3}, Attrs: AttrBgDefault}}, "\x1b[0;38;2;1;2;3;49mx"},
|
{ColorModeTrueColor, []Cell{{Rune: 'x', Fg: color.RGB{R: 1, G: 2, B: 3}, Attrs: AttrBgDefault}}, "\x1b[0;38;2;1;2;3;49mx"},
|
||||||
{ColorModeNone, []Cell{{Rune: 'x', Fg: red, Bg: yellow, Attrs: AttrFg256 | AttrUnderline}, {Rune: 'y', Fg: yellow, Attrs: AttrUnderline}}, "\x1b[0;4mxy"},
|
{ColorModeNone, []Cell{{Rune: 'x', Fg: red, Bg: yellow, Attrs: AttrFg256 | AttrUnderline}, {Rune: 'y', Fg: yellow, Attrs: AttrUnderline}}, "\x1b[0;4mxy"},
|
||||||
} {
|
} {
|
||||||
f := &fakeBackend{}
|
o := newOutputBuffer(c.mode)
|
||||||
o := newOutputBuffer(f, c.mode)
|
|
||||||
o.flush(c.cells, len(c.cells), 1)
|
o.flush(c.cells, len(c.cells), 1)
|
||||||
if got, want := f.written(), "\x1b[1;1H"+c.want+"\x1b[0m"; got != want {
|
if got, want := o.buf.String(), "\x1b[1;1H"+c.want+"\x1b[0m"; got != want {
|
||||||
t.Errorf("mode %d %+v: %q, want %q", c.mode, c.cells, got, want)
|
t.Errorf("mode %d %+v: %q, want %q", c.mode, c.cells, got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,10 +97,9 @@ func TestColorModesWriteWhatTheTerminalReads(t *testing.T) {
|
|||||||
// value or a log line carried in, or no rune at all, is drawn as a space,
|
// value or a log line carried in, or no rune at all, is drawn as a space,
|
||||||
// never sent to the terminal
|
// never sent to the terminal
|
||||||
func TestCellsSendNoControlCharacters(t *testing.T) {
|
func TestCellsSendNoControlCharacters(t *testing.T) {
|
||||||
f := &fakeBackend{}
|
o := newOutputBuffer(ColorMode256)
|
||||||
o := newOutputBuffer(f, ColorMode256)
|
|
||||||
o.flush([]Cell{{Rune: 0x1b}, {Rune: 0x9b}, {Rune: 0x7f}, {Rune: -229}, {Rune: 0xd800}, {Rune: 0x202e}, {Rune: 0x2067}, {Rune: 0x061c}, {Rune: 0x200b}, {Rune: 0x2028}, {Rune: 'é'}}, 11, 1)
|
o.flush([]Cell{{Rune: 0x1b}, {Rune: 0x9b}, {Rune: 0x7f}, {Rune: -229}, {Rune: 0xd800}, {Rune: 0x202e}, {Rune: 0x2067}, {Rune: 0x061c}, {Rune: 0x200b}, {Rune: 0x2028}, {Rune: 'é'}}, 11, 1)
|
||||||
if got, want := f.written(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m é\x1b[0m"; got != want {
|
if got, want := o.buf.String(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m é\x1b[0m"; got != want {
|
||||||
t.Fatalf("%q, want %q", got, want)
|
t.Fatalf("%q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,14 +109,13 @@ func TestCellsSendNoControlCharacters(t *testing.T) {
|
|||||||
// (a spacing mark, a letter such as Thai's sara am) is drawn as U+FFFD, any
|
// (a spacing mark, a letter such as Thai's sara am) is drawn as U+FFFD, any
|
||||||
// other as itself
|
// other as itself
|
||||||
func TestEachCellIsOneColumn(t *testing.T) {
|
func TestEachCellIsOneColumn(t *testing.T) {
|
||||||
f := &fakeBackend{}
|
o := newOutputBuffer(ColorMode256)
|
||||||
o := newOutputBuffer(f, ColorMode256)
|
|
||||||
var row []Cell
|
var row []Cell
|
||||||
for _, r := range "\u0301\u20dd\u1161漢A😀✅\U0001f1fa\u093e\u0e33é─❤" {
|
for _, r := range "\u0301\u20dd\u1161漢A😀✅\U0001f1fa\u093e\u0e33é─❤" {
|
||||||
row = append(row, Cell{Rune: r})
|
row = append(row, Cell{Rune: r})
|
||||||
}
|
}
|
||||||
o.flush(row, len(row), 1)
|
o.flush(row, len(row), 1)
|
||||||
if got, want := f.written(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m"+strings.Repeat("\ufffd", 10)+"é─❤\x1b[0m"; got != want {
|
if got, want := o.buf.String(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m"+strings.Repeat("\ufffd", 10)+"é─❤\x1b[0m"; got != want {
|
||||||
t.Fatalf("%q, want %q", got, want)
|
t.Fatalf("%q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,8 +184,7 @@ func expectEvents(t *testing.T, cases map[string]input) {
|
|||||||
// Paste mode set on the terminal is what its reader parses by
|
// Paste mode set on the terminal is what its reader parses by
|
||||||
func TestPasteModeReachesTheReader(t *testing.T) {
|
func TestPasteModeReachesTheReader(t *testing.T) {
|
||||||
f := &fakeBackend{}
|
f := &fakeBackend{}
|
||||||
term := &termImpl{backend: f, syntheticCh: make(chan Event, 1), resizeCh: make(chan ResizeEvent, 1)}
|
term := newTerm(f, ColorMode256)
|
||||||
term.output = newOutputBuffer(f, ColorMode256)
|
|
||||||
if err := term.Init(); err != nil {
|
if err := term.Init(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -210,8 +210,7 @@ func TestPasteModeReachesTheReader(t *testing.T) {
|
|||||||
// than painting it black
|
// than painting it black
|
||||||
func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) {
|
func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) {
|
||||||
f := &fakeBackend{}
|
f := &fakeBackend{}
|
||||||
term := &termImpl{backend: f, syntheticCh: make(chan Event, 1), resizeCh: make(chan ResizeEvent, 1)}
|
term := newTerm(f, ColorModeTrueColor)
|
||||||
term.output = newOutputBuffer(f, ColorModeTrueColor)
|
|
||||||
if err := term.Init(); err != nil {
|
if err := term.Init(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -228,3 +227,73 @@ func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) {
|
|||||||
t.Fatalf("bidi reordering not off for the session: %q", got)
|
t.Fatalf("bidi reordering not off for the session: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A terminal that stops reading holds the write in flight, not whoever draws:
|
||||||
|
// Flush returns while it is out, Blocked says how long it has waited, the newest
|
||||||
|
// frame held meanwhile is the one drawn once the terminal reads again, and a
|
||||||
|
// control sequence asked for after a held frame reaches the terminal after it
|
||||||
|
func TestAStoppedTerminalHoldsNoCaller(t *testing.T) {
|
||||||
|
f := &fakeBackend{gate: make(chan struct{})}
|
||||||
|
term := newTerm(f, ColorMode256)
|
||||||
|
if err := term.Init(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
waitFor(t, "the first write to go out", func() bool { return term.Blocked() > 0 })
|
||||||
|
|
||||||
|
frame := func(r rune) []Cell { return []Cell{{Rune: r}} }
|
||||||
|
returned := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
term.Flush(frame('a'), 1, 1)
|
||||||
|
term.Flush(frame('b'), 1, 1)
|
||||||
|
close(returned)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-returned:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("Flush waited on a terminal that stopped reading")
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
if b := term.Blocked(); b < 10*time.Millisecond {
|
||||||
|
t.Fatalf("blocked %v while the write is held", b)
|
||||||
|
}
|
||||||
|
term.SetCursorVisible(true)
|
||||||
|
|
||||||
|
close(f.gate)
|
||||||
|
waitFor(t, "output to drain", func() bool { return term.Blocked() == 0 && strings.Contains(f.written(), "\x1b[?25h") })
|
||||||
|
got := f.written()
|
||||||
|
if strings.Contains(got, "a") || !strings.Contains(got, "b") {
|
||||||
|
t.Fatalf("drew %q; want the newest held frame only", got)
|
||||||
|
}
|
||||||
|
if strings.Index(got, "b") > strings.Index(got, "\x1b[?25h") {
|
||||||
|
t.Fatalf("the cursor was shown before the frame held ahead of it: %q", got)
|
||||||
|
}
|
||||||
|
term.Fini()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fini restores the termios even when the terminal never reads its last
|
||||||
|
// sequences: it stops waiting after finiDrainTimeout
|
||||||
|
func TestFiniLeavesAStoppedTerminal(t *testing.T) {
|
||||||
|
f := &fakeBackend{gate: make(chan struct{})}
|
||||||
|
defer close(f.gate)
|
||||||
|
term := newTerm(f, ColorMode256)
|
||||||
|
if err := term.Init(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { term.Fini(); close(done) }()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(finiDrainTimeout + 2*time.Second):
|
||||||
|
t.Fatal("Fini waited on a terminal that stopped reading")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitFor(t *testing.T, what string, ok func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
for deadline := time.Now().Add(2 * time.Second); !ok(); {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("timed out waiting for %s", what)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package terminal
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// sendLocked queues what the operation in progress rendered and wakes the writer.
|
||||||
|
// Caller MUST hold mu.
|
||||||
|
func (t *termImpl) sendLocked() {
|
||||||
|
buf := &t.output.buf
|
||||||
|
if buf.Len() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.queued = append(t.queued, buf.Bytes()...)
|
||||||
|
buf.Reset()
|
||||||
|
select {
|
||||||
|
case t.wake <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeLoop is the only writer of terminal output after Init. It returns once Fini
|
||||||
|
// has queued the restoring sequences and they are written; a terminal that never
|
||||||
|
// takes them leaves it in its write, and Fini stops waiting.
|
||||||
|
func (t *termImpl) writeLoop() {
|
||||||
|
defer close(t.writerDone)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-t.wake:
|
||||||
|
t.drain()
|
||||||
|
case <-t.quit:
|
||||||
|
t.drain()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// drain writes what is queued until nothing is, and draws a frame held while a
|
||||||
|
// write was out once that write returns. The write runs without mu, so whoever
|
||||||
|
// draws meanwhile finds writeSince set and holds its frame instead of waiting.
|
||||||
|
func (t *termImpl) drain() {
|
||||||
|
t.mu.Lock()
|
||||||
|
for {
|
||||||
|
if len(t.queued) == 0 {
|
||||||
|
t.releaseLocked()
|
||||||
|
}
|
||||||
|
if len(t.queued) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
t.writing, t.queued = t.queued, t.writing[:0]
|
||||||
|
t.writeSince.Store(time.Now().UnixNano())
|
||||||
|
t.mu.Unlock()
|
||||||
|
_ = t.backend.Write(t.writing) // a failed write is lost, as one through a buffered writer was
|
||||||
|
t.writeSince.Store(0)
|
||||||
|
t.mu.Lock()
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
Reference in new issue
Block a user