v0.3.0 write output on its own goroutine, blocked terminal report
This commit is contained in:
7 files changed
+290
-80
No files matched your search
@@ -24,7 +24,8 @@ consoles with VT processing, and browsers via xterm.js (WASM builds).
|
||||
|
||||
Terminal (interface)
|
||||
└── 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
|
||||
└── Backend (interface)
|
||||
├── 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
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
)
|
||||
|
||||
// Pre-allocated ANSI sequence fragments (avoid allocations during render)
|
||||
@@ -49,7 +49,7 @@ var (
|
||||
|
||||
// writeInt writes an integer without allocation
|
||||
// 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 {
|
||||
n = 0
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func writeInt(w *bufio.Writer, n int) {
|
||||
}
|
||||
|
||||
// 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)
|
||||
writeInt(w, y+1)
|
||||
w.WriteByte(';')
|
||||
@@ -88,7 +88,7 @@ func writeCursorPos(w *bufio.Writer, x, y int) {
|
||||
}
|
||||
|
||||
// writeCursorForward writes cursor forward N positions
|
||||
func writeCursorForward(w *bufio.Writer, n int) {
|
||||
func writeCursorForward(w *bytes.Buffer, n int) {
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -39,8 +39,11 @@
|
||||
//
|
||||
// # Performance
|
||||
//
|
||||
// Output uses 128KB buffered writer with cell-level diffing. Only changed cells
|
||||
// generate ANSI sequences. Style attributes are coalesced to minimize SGR calls.
|
||||
// Output uses cell-level diffing. Only changed cells generate ANSI sequences.
|
||||
// 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.
|
||||
//
|
||||
// This package bypasses terminfo/termcap entirely, emitting direct ANSI sequences.
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"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 {
|
||||
front []Cell
|
||||
width int
|
||||
height int
|
||||
colorMode ColorMode
|
||||
writer *bufio.Writer
|
||||
buf bytes.Buffer
|
||||
|
||||
cursorX int
|
||||
cursorY int
|
||||
@@ -27,7 +29,7 @@ type outputBuffer struct {
|
||||
lastValid bool
|
||||
}
|
||||
|
||||
// writerAdapter adapts Backend to io.Writer for bufio
|
||||
// writerAdapter adapts Backend to io.Writer
|
||||
type writerAdapter struct {
|
||||
b Backend
|
||||
}
|
||||
@@ -40,13 +42,8 @@ func (wa writerAdapter) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func newOutputBuffer(backend Backend, colorMode ColorMode) *outputBuffer {
|
||||
// Use 128KB buffer for minimal calls to backend
|
||||
adapter := writerAdapter{b: backend}
|
||||
return &outputBuffer{
|
||||
writer: bufio.NewWriterSize(adapter, 131072),
|
||||
colorMode: colorMode,
|
||||
}
|
||||
func newOutputBuffer(colorMode ColorMode) *outputBuffer {
|
||||
return &outputBuffer{colorMode: colorMode}
|
||||
}
|
||||
|
||||
func (o *outputBuffer) resize(width, height int) {
|
||||
@@ -74,7 +71,7 @@ func cellEqual(a, b Cell) bool {
|
||||
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) {
|
||||
if width != o.width || height != o.height {
|
||||
o.resize(width, height)
|
||||
@@ -85,7 +82,7 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
||||
return
|
||||
}
|
||||
|
||||
w := o.writer
|
||||
w := &o.buf
|
||||
|
||||
for y := 0; y < height; y++ {
|
||||
rowStart := y * width
|
||||
@@ -178,7 +175,6 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
|
||||
|
||||
w.Write(csiSGR0)
|
||||
o.lastValid = false
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
// 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
|
||||
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 {
|
||||
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
|
||||
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 {
|
||||
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
|
||||
// base 30 (foreground) or 40 (background): the terminal's own, a palette
|
||||
// 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 {
|
||||
return
|
||||
}
|
||||
@@ -418,10 +414,10 @@ func (o *outputBuffer) forceFullRedraw() {
|
||||
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
|
||||
func (o *outputBuffer) clear(bg color.RGB, attr Attr) {
|
||||
w := o.writer
|
||||
w := &o.buf
|
||||
w.Write(csiSGR0)
|
||||
w.Write(csi)
|
||||
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.cursorValid = false
|
||||
w.Flush()
|
||||
|
||||
for i := range o.front {
|
||||
o.front[i] = Cell{Rune: ' ', Bg: bg, Attrs: attr & AttrBgColor}
|
||||
|
||||
+117
-40
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/lixenwraith/color"
|
||||
"golang.org/x/term"
|
||||
@@ -61,10 +62,16 @@ type Terminal interface {
|
||||
// ColorMode returns detected color capability
|
||||
ColorMode() ColorMode
|
||||
|
||||
// Flush writes cell buffer to terminal
|
||||
// Cells are row-major: cells[y*width + x]
|
||||
// Flush hands a frame to the terminal; 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)
|
||||
|
||||
// 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(bg color.RGB)
|
||||
|
||||
@@ -114,8 +121,28 @@ type termImpl struct {
|
||||
finalized bool
|
||||
mouseMode MouseMode
|
||||
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
|
||||
// DetectColorMode's, or ColorModeNone when NO_COLOR is set; an explicit mode
|
||||
// overrides both, as a --color=always or never would
|
||||
@@ -129,15 +156,19 @@ func New(colorMode ...ColorMode) Terminal {
|
||||
c = ColorModeNone
|
||||
}
|
||||
|
||||
t := &termImpl{
|
||||
backend: b,
|
||||
syntheticCh: make(chan Event, 16),
|
||||
resizeCh: make(chan ResizeEvent, 1),
|
||||
return newTerm(b, c)
|
||||
}
|
||||
|
||||
// Initialize output buffer with backend
|
||||
t.output = newOutputBuffer(b, c)
|
||||
return t
|
||||
func newTerm(b Backend, c ColorMode) *termImpl {
|
||||
return &termImpl{
|
||||
backend: b,
|
||||
output: newOutputBuffer(c),
|
||||
syntheticCh: make(chan Event, 16),
|
||||
resizeCh: make(chan ResizeEvent, 1),
|
||||
wake: make(chan struct{}, 1),
|
||||
quit: make(chan struct{}),
|
||||
writerDone: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
t.writeRaw(csiAltScreenEnter)
|
||||
t.writeRaw(csiCursorHide)
|
||||
out := &t.output.buf
|
||||
out.Write(csiAltScreenEnter)
|
||||
out.Write(csiCursorHide)
|
||||
|
||||
// DISABLE AUTO-WRAP
|
||||
// Prevents terminal scroll/wrap on bottom-right corner write
|
||||
t.writeRaw(csiAutoWrapOff)
|
||||
out.Write(csiAutoWrapOff)
|
||||
|
||||
// Invisible cursor
|
||||
t.cursorVisible.Store(false)
|
||||
@@ -196,6 +230,7 @@ func (t *termImpl) Init() error {
|
||||
|
||||
// Clear screen
|
||||
t.output.clear(color.RGB{}, AttrBgDefault)
|
||||
t.sendLocked()
|
||||
|
||||
// Start input reader
|
||||
t.input.start()
|
||||
@@ -207,24 +242,25 @@ func (t *termImpl) Init() error {
|
||||
// Fini restores terminal state
|
||||
func (t *termImpl) Fini() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if !t.initialized || t.finalized {
|
||||
t.mu.Unlock()
|
||||
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
|
||||
if t.mouseMode != MouseModeNone {
|
||||
w := t.output.writer
|
||||
w.Write(csiMouseMotionOff)
|
||||
w.Write(csiMouseDragOff)
|
||||
w.Write(csiMouseClickOff)
|
||||
w.Write(csiMouseSGROff)
|
||||
w.Flush()
|
||||
t.sendLocked()
|
||||
}
|
||||
|
||||
if t.paste {
|
||||
t.writeRaw(csiPasteOff)
|
||||
w.Write(csiPasteOff)
|
||||
}
|
||||
|
||||
// Stop handlers
|
||||
@@ -233,21 +269,29 @@ func (t *termImpl) Fini() {
|
||||
}
|
||||
|
||||
// Show cursor
|
||||
t.writeRaw(csiCursorShow)
|
||||
w.Write(csiCursorShow)
|
||||
|
||||
// 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
|
||||
t.writeRaw(csiAutoWrapOn)
|
||||
w.Write(csiAutoWrapOn)
|
||||
|
||||
// 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
|
||||
t.backend.Fini()
|
||||
|
||||
t.finalized = true
|
||||
}
|
||||
|
||||
// Size returns current terminal dimensions
|
||||
@@ -265,8 +309,8 @@ func (t *termImpl) ColorMode() ColorMode {
|
||||
return t.output.colorMode
|
||||
}
|
||||
|
||||
// Flush writes cell buffer to terminal
|
||||
// Holds lock for entire operation to prevent race with Clear/MoveCursor
|
||||
// Flush renders cells and queues them for the terminal, or holds them while a write
|
||||
// is out. Holds lock for entire operation to prevent race with Clear/MoveCursor
|
||||
func (t *termImpl) Flush(cells []Cell, width, height int) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
@@ -275,6 +319,18 @@ func (t *termImpl) Flush(cells []Cell, width, height int) {
|
||||
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
|
||||
currW, currH := t.backend.Size()
|
||||
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.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
|
||||
@@ -293,7 +367,9 @@ func (t *termImpl) Clear(bg color.RGB) {
|
||||
return
|
||||
}
|
||||
|
||||
t.releaseLocked()
|
||||
t.output.clear(bg, 0)
|
||||
t.sendLocked()
|
||||
}
|
||||
|
||||
// SetCursorVisible shows/hides cursor
|
||||
@@ -309,13 +385,14 @@ func (t *termImpl) SetCursorVisible(visible bool) {
|
||||
return
|
||||
}
|
||||
|
||||
w := t.output.writer
|
||||
t.releaseLocked()
|
||||
w := &t.output.buf
|
||||
if visible {
|
||||
w.Write(csiCursorShow)
|
||||
} else {
|
||||
w.Write(csiCursorHide)
|
||||
}
|
||||
w.Flush()
|
||||
t.sendLocked()
|
||||
}
|
||||
|
||||
// MoveCursor positions cursor (0-indexed)
|
||||
@@ -327,6 +404,7 @@ func (t *termImpl) MoveCursor(x, y int) {
|
||||
return
|
||||
}
|
||||
|
||||
t.releaseLocked()
|
||||
if t.output != nil {
|
||||
t.output.invalidateCursor()
|
||||
}
|
||||
@@ -345,10 +423,9 @@ func (t *termImpl) MoveCursor(x, y int) {
|
||||
y = h - 1
|
||||
}
|
||||
|
||||
// Write through buffered writer to maintain stream order
|
||||
wBuf := t.output.writer
|
||||
writeCursorPos(wBuf, x, y)
|
||||
wBuf.Flush()
|
||||
// Through the queue, to keep the stream's order
|
||||
writeCursorPos(&t.output.buf, x, y)
|
||||
t.sendLocked()
|
||||
}
|
||||
|
||||
// Sync forces full redraw
|
||||
@@ -362,8 +439,10 @@ func (t *termImpl) Sync() {
|
||||
|
||||
// Clear terminal before full redraw
|
||||
// Diff-based rendering assumes physical terminal matches front buffer state
|
||||
t.releaseLocked()
|
||||
t.output.clear(color.RGB{}, AttrBgDefault)
|
||||
t.output.forceFullRedraw()
|
||||
t.sendLocked()
|
||||
}
|
||||
|
||||
// PollEvent blocks until next input event
|
||||
@@ -412,7 +491,8 @@ func (t *termImpl) SetMouseMode(mode MouseMode) error {
|
||||
oldMode := t.mouseMode
|
||||
t.mouseMode = mode
|
||||
|
||||
w := t.output.writer
|
||||
t.releaseLocked()
|
||||
w := &t.output.buf
|
||||
|
||||
// Disable modes no longer needed (reverse order of enable)
|
||||
if oldMode&MouseModeMotion != 0 && mode&MouseModeMotion == 0 {
|
||||
@@ -446,7 +526,7 @@ func (t *termImpl) SetMouseMode(mode MouseMode) error {
|
||||
w.Write(csiMouseMotionOn)
|
||||
}
|
||||
|
||||
w.Flush()
|
||||
t.sendLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -460,18 +540,15 @@ func (t *termImpl) SetPasteMode(on bool) error {
|
||||
}
|
||||
t.paste = on
|
||||
t.input.pasteMode.Store(on)
|
||||
w := t.output.writer
|
||||
t.releaseLocked()
|
||||
w := &t.output.buf
|
||||
if on {
|
||||
w.Write(csiPasteOn)
|
||||
} else {
|
||||
w.Write(csiPasteOff)
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
// writeRaw writes raw bytes to output
|
||||
func (t *termImpl) writeRaw(data []byte) {
|
||||
t.backend.Write(data)
|
||||
t.sendLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmergencyReset attempts to restore terminal to sane state
|
||||
|
||||
+82
-13
@@ -18,6 +18,7 @@ type fakeBackend struct {
|
||||
out bytes.Buffer
|
||||
chunks []string
|
||||
pause time.Duration
|
||||
gate chan struct{} // when set, a write waits until it is closed
|
||||
}
|
||||
|
||||
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) Write(p []byte) error {
|
||||
if f.gate != nil {
|
||||
<-f.gate
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
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"},
|
||||
{ColorModeNone, []Cell{{Rune: 'x', Fg: red, Bg: yellow, Attrs: AttrFg256 | AttrUnderline}, {Rune: 'y', Fg: yellow, Attrs: AttrUnderline}}, "\x1b[0;4mxy"},
|
||||
} {
|
||||
f := &fakeBackend{}
|
||||
o := newOutputBuffer(f, c.mode)
|
||||
o := newOutputBuffer(c.mode)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
// never sent to the terminal
|
||||
func TestCellsSendNoControlCharacters(t *testing.T) {
|
||||
f := &fakeBackend{}
|
||||
o := newOutputBuffer(f, ColorMode256)
|
||||
o := newOutputBuffer(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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// other as itself
|
||||
func TestEachCellIsOneColumn(t *testing.T) {
|
||||
f := &fakeBackend{}
|
||||
o := newOutputBuffer(f, ColorMode256)
|
||||
o := newOutputBuffer(ColorMode256)
|
||||
var row []Cell
|
||||
for _, r := range "\u0301\u20dd\u1161漢A😀✅\U0001f1fa\u093e\u0e33é─❤" {
|
||||
row = append(row, Cell{Rune: r})
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
func TestPasteModeReachesTheReader(t *testing.T) {
|
||||
f := &fakeBackend{}
|
||||
term := &termImpl{backend: f, syntheticCh: make(chan Event, 1), resizeCh: make(chan ResizeEvent, 1)}
|
||||
term.output = newOutputBuffer(f, ColorMode256)
|
||||
term := newTerm(f, ColorMode256)
|
||||
if err := term.Init(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -210,8 +210,7 @@ func TestPasteModeReachesTheReader(t *testing.T) {
|
||||
// than painting it black
|
||||
func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) {
|
||||
f := &fakeBackend{}
|
||||
term := &termImpl{backend: f, syntheticCh: make(chan Event, 1), resizeCh: make(chan ResizeEvent, 1)}
|
||||
term.output = newOutputBuffer(f, ColorModeTrueColor)
|
||||
term := newTerm(f, ColorModeTrueColor)
|
||||
if err := term.Init(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -228,3 +227,73 @@ func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) {
|
||||
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