diff --git a/buffer/buffer.go b/buffer/buffer.go index 80670e8..57a7b35 100644 --- a/buffer/buffer.go +++ b/buffer/buffer.go @@ -21,13 +21,17 @@ const ( SelectionRegionRectangular ) +type Notifier interface { + Notify() +} + type Buffer struct { lines []Line displayChangeHandlers []chan bool savedX uint16 savedY uint16 savedCursorAttr *CellAttributes - dirty bool + dirty Notifier selectionStart *Position selectionEnd *Position selectionMode SelectionMode @@ -54,7 +58,7 @@ func comparePositions(pos1 *Position, pos2 *Position) int { } // NewBuffer creates a new terminal buffer -func NewBuffer(terminalState *TerminalState) *Buffer { +func NewBuffer(terminalState *TerminalState, dirty Notifier) *Buffer { b := &Buffer{ lines: []Line{}, selectionStart: nil, @@ -62,6 +66,7 @@ func NewBuffer(terminalState *TerminalState) *Buffer { selectionMode: SelectionChar, isSelectionComplete: true, terminalState: terminalState, + dirty: dirty, } return b } @@ -251,17 +256,15 @@ func (buffer *Buffer) StartSelection(col uint16, viewRow uint16, mode SelectionM } buffer.isSelectionComplete = false - - buffer.emitDisplayChange() + buffer.dirty.Notify() } func (buffer *Buffer) ExtendSelection(col uint16, viewRow uint16, complete bool) { - if buffer.isSelectionComplete { return } - defer buffer.emitDisplayChange() + defer buffer.dirty.Notify() if buffer.selectionStart == nil { buffer.selectionEnd = nil @@ -285,7 +288,7 @@ func (buffer *Buffer) ClearSelection() { buffer.selectionEnd = nil buffer.isSelectionComplete = true - buffer.emitDisplayChange() + buffer.dirty.Notify() } func (buffer *Buffer) getActualSelection(selectionRegionMode SelectionRegionMode) (*Position, *Position) { @@ -368,14 +371,6 @@ func (buffer *Buffer) InSelection(col uint16, row uint16, selectionRegionMode Se (rawY < end.Line || (rawY == end.Line && int(col) <= end.Col)) } -func (buffer *Buffer) IsDirty() bool { - if !buffer.dirty { - return false - } - buffer.dirty = false - return true -} - func (buffer *Buffer) HasScrollableRegion() bool { return buffer.terminalState.topMargin > 0 || buffer.terminalState.bottomMargin < uint(buffer.ViewHeight())-1 } @@ -395,7 +390,7 @@ func (buffer *Buffer) getAreaScrollRange() (top uint64, bottom uint64) { } func (buffer *Buffer) AreaScrollDown(lines uint16) { - defer buffer.emitDisplayChange() + defer buffer.dirty.Notify() // NOTE: bottom is exclusive top, bottom := buffer.getAreaScrollRange() @@ -411,7 +406,7 @@ func (buffer *Buffer) AreaScrollDown(lines uint16) { } func (buffer *Buffer) AreaScrollUp(lines uint16) { - defer buffer.emitDisplayChange() + defer buffer.dirty.Notify() // NOTE: bottom is exclusive top, bottom := buffer.getAreaScrollRange() @@ -471,10 +466,6 @@ func (buffer *Buffer) GetRawCell(viewCol uint16, rawLine uint64) *Cell { return &line.cells[viewCol] } -func (buffer *Buffer) emitDisplayChange() { - buffer.dirty = true -} - // Column returns cursor column func (buffer *Buffer) CursorColumn() uint16 { // @todo originMode and left margin @@ -550,8 +541,7 @@ func (buffer *Buffer) deleteLine() { } func (buffer *Buffer) insertLine() { - - defer buffer.emitDisplayChange() + defer buffer.dirty.Notify() if !buffer.InScrollableRegion() { pos := buffer.RawLine() @@ -636,7 +626,7 @@ func (buffer *Buffer) Index() { // This sequence causes the active position to move downward one line without changing the column position. // If the active position is at the bottom margin, a scroll up is performed." - defer buffer.emitDisplayChange() + defer buffer.dirty.Notify() if buffer.InScrollableRegion() { @@ -662,8 +652,7 @@ func (buffer *Buffer) Index() { } func (buffer *Buffer) ReverseIndex() { - - defer buffer.emitDisplayChange() + defer buffer.dirty.Notify() if uint(buffer.terminalState.cursorY) == buffer.terminalState.topMargin { buffer.AreaScrollDown(1) @@ -674,7 +663,8 @@ func (buffer *Buffer) ReverseIndex() { // Write will write a rune to the terminal at the position of the cursor, and increment the cursor position func (buffer *Buffer) Write(runes ...rune) { - defer buffer.emitDisplayChange() + defer buffer.dirty.Notify() + // scroll to bottom on input buffer.terminalState.scrollLinesFromBottom = 0 @@ -837,7 +827,7 @@ func (buffer *Buffer) MovePosition(x int16, y int16) { } func (buffer *Buffer) SetPosition(col uint16, line uint16) { - defer buffer.emitDisplayChange() + defer buffer.dirty.Notify() useCol := col useLine := line @@ -876,7 +866,7 @@ func (buffer *Buffer) GetVisibleLines() []Line { // tested to here func (buffer *Buffer) Clear() { - defer buffer.emitDisplayChange() + defer buffer.dirty.Notify() for i := 0; i < int(buffer.ViewHeight()); i++ { buffer.lines = append(buffer.lines, newLine()) } @@ -916,13 +906,13 @@ func (buffer *Buffer) getViewLine(index uint16) *Line { } func (buffer *Buffer) EraseLine() { - defer buffer.emitDisplayChange() + defer buffer.dirty.Notify() line := buffer.getCurrentLine() line.cells = []Cell{} } func (buffer *Buffer) EraseLineToCursor() { - defer buffer.emitDisplayChange() + defer buffer.dirty.Notify() line := buffer.getCurrentLine() for i := 0; i <= int(buffer.terminalState.cursorX); i++ { if i < len(line.cells) { @@ -932,7 +922,7 @@ func (buffer *Buffer) EraseLineToCursor() { } func (buffer *Buffer) EraseLineFromCursor() { - defer buffer.emitDisplayChange() + defer buffer.dirty.Notify() line := buffer.getCurrentLine() if len(line.cells) > 0 { @@ -950,7 +940,7 @@ func (buffer *Buffer) EraseLineFromCursor() { } func (buffer *Buffer) EraseDisplay() { - defer buffer.emitDisplayChange() + defer buffer.dirty.Notify() for i := uint16(0); i < (buffer.ViewHeight()); i++ { rawLine := buffer.convertViewLineToRawLine(i) if int(rawLine) < len(buffer.lines) { @@ -960,7 +950,7 @@ func (buffer *Buffer) EraseDisplay() { } func (buffer *Buffer) DeleteChars(n int) { - defer buffer.emitDisplayChange() + defer buffer.dirty.Notify() line := buffer.getCurrentLine() if int(buffer.terminalState.cursorX) >= len(line.cells) { @@ -975,7 +965,7 @@ func (buffer *Buffer) DeleteChars(n int) { } func (buffer *Buffer) EraseCharacters(n int) { - defer buffer.emitDisplayChange() + defer buffer.dirty.Notify() line := buffer.getCurrentLine() @@ -990,7 +980,7 @@ func (buffer *Buffer) EraseCharacters(n int) { } func (buffer *Buffer) EraseDisplayFromCursor() { - defer buffer.emitDisplayChange() + defer buffer.dirty.Notify() line := buffer.getCurrentLine() max := int(buffer.terminalState.cursorX) @@ -1006,7 +996,7 @@ func (buffer *Buffer) EraseDisplayFromCursor() { } func (buffer *Buffer) EraseDisplayToCursor() { - defer buffer.emitDisplayChange() + defer buffer.dirty.Notify() line := buffer.getCurrentLine() for i := 0; i <= int(buffer.terminalState.cursorX); i++ { @@ -1024,8 +1014,7 @@ func (buffer *Buffer) EraseDisplayToCursor() { } func (buffer *Buffer) ResizeView(width uint16, height uint16) { - - defer buffer.emitDisplayChange() + defer buffer.dirty.Notify() if buffer.terminalState.viewHeight == 0 { buffer.terminalState.viewWidth = width @@ -1162,7 +1151,7 @@ func (buffer *Buffer) CompareViewLines(path string) bool { } func (buffer *Buffer) ReverseVideo() { - defer buffer.emitDisplayChange() + defer buffer.dirty.Notify() for _, line := range buffer.lines { line.ReverseVideo() diff --git a/buffer/buffer_test.go b/buffer/buffer_test.go index 43064bf..a62abc0 100644 --- a/buffer/buffer_test.go +++ b/buffer/buffer_test.go @@ -9,8 +9,24 @@ import ( "github.com/stretchr/testify/assert" ) +func TestBufferCreation(t *testing.T) { + b, n := makeBufferForTesting(10, 20) + assert.Equal(t, uint16(10), b.Width()) + assert.Equal(t, uint16(20), b.ViewHeight()) + assert.Equal(t, uint16(0), b.CursorColumn()) + assert.Equal(t, uint16(0), b.CursorLine()) + assert.NotNil(t, b.lines) + n.AssertNotNotified(t) +} + +func TestWriteNotify(t *testing.T) { + b, n := makeBufferForTesting(30, 3) + b.Write(rune('x')) + n.AssertNotified(t) +} + func TestTabbing(t *testing.T) { - b := NewBuffer(NewTerminalState(30, 3, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(30, 3) b.Write([]rune("hello")...) b.Tab() b.Write([]rune("x")...) @@ -39,7 +55,7 @@ hell xxx good } func TestOffsets(t *testing.T) { - b := NewBuffer(NewTerminalState(10, 3, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(10, 3) b.Write([]rune("hello")...) b.CarriageReturn() b.NewLine() @@ -59,18 +75,8 @@ func TestOffsets(t *testing.T) { assert.Equal(t, 5, b.Height()) } -func TestBufferCreation(t *testing.T) { - b := NewBuffer(NewTerminalState(10, 20, CellAttributes{}, 1000)) - assert.Equal(t, uint16(10), b.Width()) - assert.Equal(t, uint16(20), b.ViewHeight()) - assert.Equal(t, uint16(0), b.CursorColumn()) - assert.Equal(t, uint16(0), b.CursorLine()) - assert.NotNil(t, b.lines) -} - func TestBufferWriteIncrementsCursorCorrectly(t *testing.T) { - - b := NewBuffer(NewTerminalState(5, 4, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(5, 4) /*01234 |----- @@ -117,7 +123,7 @@ func TestBufferWriteIncrementsCursorCorrectly(t *testing.T) { } func TestWritingNewLineAsFirstRuneOnWrappedLine(t *testing.T) { - b := NewBuffer(NewTerminalState(3, 20, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(3, 20) b.terminalState.LineFeedMode = false b.Write('a', 'b', 'c') @@ -142,7 +148,7 @@ func TestWritingNewLineAsFirstRuneOnWrappedLine(t *testing.T) { } func TestWritingNewLineAsSecondRuneOnWrappedLine(t *testing.T) { - b := NewBuffer(NewTerminalState(3, 20, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(3, 20) b.terminalState.LineFeedMode = false /* |abc @@ -170,46 +176,59 @@ func TestWritingNewLineAsSecondRuneOnWrappedLine(t *testing.T) { } func TestSetPosition(t *testing.T) { - - b := NewBuffer(NewTerminalState(120, 80, CellAttributes{}, 1000)) + b, n := makeBufferForTesting(120, 80) assert.Equal(t, 0, int(b.CursorColumn())) assert.Equal(t, 0, int(b.CursorLine())) + b.SetPosition(60, 10) assert.Equal(t, 60, int(b.CursorColumn())) assert.Equal(t, 10, int(b.CursorLine())) + n.AssertNotified(t) + b.SetPosition(0, 0) assert.Equal(t, 0, int(b.CursorColumn())) assert.Equal(t, 0, int(b.CursorLine())) + n.AssertNotified(t) + b.SetPosition(120, 90) assert.Equal(t, 119, int(b.CursorColumn())) assert.Equal(t, 79, int(b.CursorLine())) - + n.AssertNotified(t) } func TestMovePosition(t *testing.T) { - b := NewBuffer(NewTerminalState(120, 80, CellAttributes{}, 1000)) + b, n := makeBufferForTesting(120, 80) assert.Equal(t, 0, int(b.CursorColumn())) assert.Equal(t, 0, int(b.CursorLine())) + b.MovePosition(-1, -1) assert.Equal(t, 0, int(b.CursorColumn())) assert.Equal(t, 0, int(b.CursorLine())) + n.AssertNotified(t) + b.MovePosition(30, 20) assert.Equal(t, 30, int(b.CursorColumn())) assert.Equal(t, 20, int(b.CursorLine())) + n.AssertNotified(t) + b.MovePosition(30, 20) assert.Equal(t, 60, int(b.CursorColumn())) assert.Equal(t, 40, int(b.CursorLine())) + n.AssertNotified(t) + b.MovePosition(-1, -1) assert.Equal(t, 59, int(b.CursorColumn())) assert.Equal(t, 39, int(b.CursorLine())) + n.AssertNotified(t) + b.MovePosition(100, 100) assert.Equal(t, 119, int(b.CursorColumn())) assert.Equal(t, 79, int(b.CursorLine())) + n.AssertNotified(t) } func TestVisibleLines(t *testing.T) { - - b := NewBuffer(NewTerminalState(80, 10, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(80, 10) b.Write([]rune("hello 1")...) b.CarriageReturn() b.NewLine() @@ -259,7 +278,7 @@ func TestVisibleLines(t *testing.T) { } func TestClearWithoutFullView(t *testing.T) { - b := NewBuffer(NewTerminalState(80, 10, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(80, 10) b.Write([]rune("hello 1")...) b.CarriageReturn() b.NewLine() @@ -275,7 +294,7 @@ func TestClearWithoutFullView(t *testing.T) { } func TestClearWithFullView(t *testing.T) { - b := NewBuffer(NewTerminalState(80, 5, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(80, 5) b.Write([]rune("hello 1")...) b.CarriageReturn() b.NewLine() @@ -306,7 +325,7 @@ func TestClearWithFullView(t *testing.T) { } func TestCarriageReturn(t *testing.T) { - b := NewBuffer(NewTerminalState(80, 20, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(80, 20) b.Write([]rune("hello!")...) b.CarriageReturn() b.Write([]rune("secret")...) @@ -315,7 +334,7 @@ func TestCarriageReturn(t *testing.T) { } func TestCarriageReturnOnFullLine(t *testing.T) { - b := NewBuffer(NewTerminalState(20, 20, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(20, 20) b.Write([]rune("abcdeabcdeabcdeabcde")...) b.CarriageReturn() b.Write([]rune("xxxxxxxxxxxxxxxxxxxx")...) @@ -324,7 +343,7 @@ func TestCarriageReturnOnFullLine(t *testing.T) { } func TestCarriageReturnOnFullLastLine(t *testing.T) { - b := NewBuffer(NewTerminalState(20, 2, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(20, 2) b.NewLine() b.Write([]rune("abcdeabcdeabcdeabcde")...) b.CarriageReturn() @@ -335,7 +354,7 @@ func TestCarriageReturnOnFullLastLine(t *testing.T) { } func TestCarriageReturnOnWrappedLine(t *testing.T) { - b := NewBuffer(NewTerminalState(80, 6, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(80, 6) b.Write([]rune("hello!")...) b.CarriageReturn() b.Write([]rune("secret")...) @@ -345,7 +364,7 @@ func TestCarriageReturnOnWrappedLine(t *testing.T) { } func TestCarriageReturnOnLineThatDoesntExist(t *testing.T) { - b := NewBuffer(NewTerminalState(6, 10, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(6, 10) b.terminalState.cursorY = 3 b.CarriageReturn() assert.Equal(t, uint16(0), b.terminalState.cursorX) @@ -353,7 +372,7 @@ func TestCarriageReturnOnLineThatDoesntExist(t *testing.T) { } func TestGetCell(t *testing.T) { - b := NewBuffer(NewTerminalState(80, 20, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(80, 20) b.Write([]rune("Hello")...) b.CarriageReturn() b.NewLine() @@ -369,7 +388,7 @@ func TestGetCell(t *testing.T) { } func TestGetCellWithHistory(t *testing.T) { - b := NewBuffer(NewTerminalState(80, 2, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(80, 2) b.Write([]rune("Hello")...) b.CarriageReturn() @@ -387,7 +406,7 @@ func TestGetCellWithHistory(t *testing.T) { } func TestGetCellWithBadCursor(t *testing.T) { - b := NewBuffer(NewTerminalState(80, 2, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(80, 2) b.Write([]rune("Hello\r\nthere\r\nsomething...")...) require.Nil(t, b.GetCell(8, 3)) require.Nil(t, b.GetCell(90, 0)) @@ -395,12 +414,12 @@ func TestGetCellWithBadCursor(t *testing.T) { } func TestCursorAttr(t *testing.T) { - b := NewBuffer(NewTerminalState(80, 2, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(80, 2) assert.Equal(t, &b.terminalState.CursorAttr, b.CursorAttr()) } func TestCursorPositionQuerying(t *testing.T) { - b := NewBuffer(NewTerminalState(80, 20, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(80, 20) b.terminalState.cursorX = 17 b.terminalState.cursorY = 9 assert.Equal(t, b.terminalState.cursorX, b.CursorColumn()) @@ -408,7 +427,7 @@ func TestCursorPositionQuerying(t *testing.T) { } func TestRawPositionQuerying(t *testing.T) { - b := NewBuffer(NewTerminalState(80, 5, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(80, 5) b.Write([]rune("a")...) b.CarriageReturn() b.NewLine() @@ -445,7 +464,7 @@ func TestRawPositionQuerying(t *testing.T) { // CSI 2 K func TestEraseLine(t *testing.T) { - b := NewBuffer(NewTerminalState(80, 5, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(80, 5) b.Write([]rune("hello, this is a test")...) b.CarriageReturn() b.NewLine() @@ -457,7 +476,7 @@ func TestEraseLine(t *testing.T) { // CSI 1 K func TestEraseLineToCursor(t *testing.T) { - b := NewBuffer(NewTerminalState(80, 5, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(80, 5) b.Write([]rune("hello, this is a test")...) b.CarriageReturn() b.NewLine() @@ -471,7 +490,7 @@ func TestEraseLineToCursor(t *testing.T) { // CSI 0 K func TestEraseLineAfterCursor(t *testing.T) { - b := NewBuffer(NewTerminalState(80, 5, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(80, 5) b.Write([]rune("hello, this is a test")...) b.CarriageReturn() b.NewLine() @@ -482,7 +501,7 @@ func TestEraseLineAfterCursor(t *testing.T) { assert.Equal(t, "dele", b.lines[1].String()) } func TestEraseDisplay(t *testing.T) { - b := NewBuffer(NewTerminalState(80, 5, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(80, 5) b.Write([]rune("hello")...) b.CarriageReturn() b.NewLine() @@ -498,7 +517,7 @@ func TestEraseDisplay(t *testing.T) { } } func TestEraseDisplayToCursor(t *testing.T) { - b := NewBuffer(NewTerminalState(80, 5, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(80, 5) b.Write([]rune("hello")...) b.CarriageReturn() b.NewLine() @@ -516,7 +535,7 @@ func TestEraseDisplayToCursor(t *testing.T) { } func TestEraseDisplayFromCursor(t *testing.T) { - b := NewBuffer(NewTerminalState(80, 5, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(80, 5) b.Write([]rune("hello")...) b.CarriageReturn() b.NewLine() @@ -532,7 +551,7 @@ func TestEraseDisplayFromCursor(t *testing.T) { assert.Equal(t, "", lines[2].String()) } func TestBackspace(t *testing.T) { - b := NewBuffer(NewTerminalState(80, 5, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(80, 5) b.Write([]rune("hello")...) b.Backspace() b.Backspace() @@ -542,7 +561,7 @@ func TestBackspace(t *testing.T) { } func TestHorizontalResizeView(t *testing.T) { - b := NewBuffer(NewTerminalState(80, 10, CellAttributes{}, 1000)) + b, _ := makeBufferForTesting(80, 10) // 60 characters b.Write([]rune(`hellohellohellohellohellohellohellohellohellohellohellohello`)...) @@ -626,7 +645,7 @@ dbye */ func TestBufferMaxLines(t *testing.T) { - b := NewBuffer(NewTerminalState(80, 2, CellAttributes{}, 2)) + b := NewBuffer(NewTerminalState(80, 2, CellAttributes{}, 2), new(testNotifier)) b.terminalState.LineFeedMode = false b.Write([]rune("hello")...) @@ -640,30 +659,20 @@ func TestBufferMaxLines(t *testing.T) { assert.Equal(t, "world", b.lines[1].String()) } -func makeBufferForTestingSelection() *Buffer { - b := NewBuffer(NewTerminalState(80, 10, CellAttributes{}, 10)) - b.terminalState.LineFeedMode = false - - b.Write([]rune("The quick brown")...) - b.NewLine() - b.Write([]rune("fox jumps over")...) - b.NewLine() - b.Write([]rune("the lazy dog")...) - - return b -} - func TestSelectingChars(t *testing.T) { - b := makeBufferForTestingSelection() + b, n := makeBufferForTestingSelection() b.StartSelection(2, 0, SelectionChar) + n.AssertNotified(t) + b.ExtendSelection(4, 1, true) + n.AssertNotified(t) assert.Equal(t, "e quick brown\nfox j", b.GetSelectedText(SelectionRegionNormal)) } func TestSelectingWordsDown(t *testing.T) { - b := makeBufferForTestingSelection() + b, _ := makeBufferForTestingSelection() b.StartSelection(6, 1, SelectionWord) b.ExtendSelection(5, 2, true) @@ -672,7 +681,7 @@ func TestSelectingWordsDown(t *testing.T) { } func TestSelectingWordsUp(t *testing.T) { - b := makeBufferForTestingSelection() + b, _ := makeBufferForTestingSelection() b.StartSelection(5, 2, SelectionWord) b.ExtendSelection(6, 1, true) @@ -681,7 +690,7 @@ func TestSelectingWordsUp(t *testing.T) { } func TestSelectingLinesDown(t *testing.T) { - b := makeBufferForTestingSelection() + b, _ := makeBufferForTestingSelection() b.StartSelection(6, 1, SelectionLine) b.ExtendSelection(4, 2, true) @@ -690,7 +699,7 @@ func TestSelectingLinesDown(t *testing.T) { } func TestSelectingLineUp(t *testing.T) { - b := makeBufferForTestingSelection() + b, _ := makeBufferForTestingSelection() b.StartSelection(8, 2, SelectionLine) b.ExtendSelection(3, 1, true) @@ -699,7 +708,7 @@ func TestSelectingLineUp(t *testing.T) { } func TestSelectingAfterText(t *testing.T) { - b := makeBufferForTestingSelection() + b, _ := makeBufferForTestingSelection() b.StartSelection(6, 3, SelectionChar) b.ExtendSelection(6, 3, true) @@ -711,3 +720,41 @@ func TestSelectingAfterText(t *testing.T) { assert.Equal(t, end.Col, 79) assert.Equal(t, end.Line, 3) } + +func makeBufferForTestingSelection() (*Buffer, *testNotifier) { + b, n := makeBufferForTesting(80, 10) + b.terminalState.LineFeedMode = false + + b.Write([]rune("The quick brown")...) + b.NewLine() + b.Write([]rune("fox jumps over")...) + b.NewLine() + b.Write([]rune("the lazy dog")...) + + return b, n +} + +func makeBufferForTesting(cols, rows uint16) (*Buffer, *testNotifier) { + n := new(testNotifier) + b := NewBuffer(NewTerminalState(cols, rows, CellAttributes{}, 100), n) + return b, n +} + +// testNotifier implements the Notifier interface and provides helpers +// for checking it Notify was called (or not). +type testNotifier struct { + notified bool +} + +func (n *testNotifier) Notify() { + n.notified = true +} + +func (n *testNotifier) AssertNotified(t *testing.T) { + assert.True(t, n.notified) + n.notified = false +} + +func (n *testNotifier) AssertNotNotified(t *testing.T) { + assert.False(t, n.notified) +} diff --git a/gui/actions.go b/gui/actions.go index ba611c1..ed355d9 100644 --- a/gui/actions.go +++ b/gui/actions.go @@ -34,7 +34,7 @@ func actionPaste(gui *GUI) { func actionToggleDebug(gui *GUI) { gui.showDebugInfo = !gui.showDebugInfo - gui.terminal.SetDirty() + gui.terminal.NotifyDirty() } func actionSearchSelection(gui *GUI) { diff --git a/gui/gui.go b/gui/gui.go index 28cb66a..0af42eb 100644 --- a/gui/gui.go +++ b/gui/gui.go @@ -24,6 +24,12 @@ import ( "go.uber.org/zap" ) +// wakePeriod controls how often the main loop is woken up. This has +// significant impact on how Aminal feels to use. Adjust with care and +// test changes on all supported platforms. +const wakePeriod = time.Second / 120 +const halfWakePeriod = wakePeriod / 2 + type GUI struct { window *glfw.Window logger *zap.SugaredLogger @@ -362,11 +368,11 @@ func (gui *GUI) Render() error { gui.window.SetMouseButtonCallback(gui.mouseButtonCallback) gui.window.SetCursorPosCallback(gui.mouseMoveCallback) gui.window.SetRefreshCallback(func(w *glfw.Window) { - gui.terminal.SetDirtyLocked() + gui.terminal.NotifyDirty() }) gui.window.SetFocusCallback(func(w *glfw.Window, focused bool) { if focused { - gui.terminal.SetDirtyLocked() + gui.terminal.NotifyDirty() } }) gui.window.SetPosCallback(gui.windowPosChangeCallback) @@ -419,82 +425,119 @@ func (gui *GUI) Render() error { r, err := version.GetNewerRelease() if err == nil && r != nil { latestVersion = r.TagName - gui.terminal.SetDirty() + gui.terminal.NotifyDirty() } }() startTime := time.Now() showMessage := true + stop := make(chan struct{}) + go gui.waker(stop) + for !gui.window.ShouldClose() { + gui.redraw() - forceRedraw := false - - select { - case <-titleChan: - gui.window.SetTitle(gui.terminal.GetTitle()) - case <-resizeChan: - gui.resizeToTerminal() - case reverse := <-reverseChan: - gui.generateDefaultCell(reverse) - forceRedraw = true - default: - // this is more efficient than glfw.PollEvents() - glfw.WaitEventsTimeout(0.02) // up to 50fps on no input, otherwise higher - } - - if gui.terminal.CheckDirty() || forceRedraw { - - gui.redraw(true) - - if gui.showDebugInfo { - gui.textbox(2, 2, fmt.Sprintf(`Cursor: %d,%d + if gui.showDebugInfo { + gui.textbox(2, 2, fmt.Sprintf(`Cursor: %d,%d View Size: %d,%d Buffer Size: %d lines `, - gui.terminal.GetLogicalCursorX(), - gui.terminal.GetLogicalCursorY(), - gui.terminal.ActiveBuffer().ViewWidth(), - gui.terminal.ActiveBuffer().ViewHeight(), - gui.terminal.ActiveBuffer().Height(), - ), - [3]float32{1, 1, 1}, - [3]float32{0.8, 0, 0}, - ) - } - - if showMessage { - if latestVersion != "" && time.Since(startTime) < time.Second*10 && gui.terminal.ActiveBuffer().RawLine() == 0 { - time.AfterFunc(time.Second, gui.terminal.SetDirtyLocked) - _, h := gui.terminal.GetSize() - var msg string - if version.Version == "" { - msg = "You are using a development build of Aminal." - } else { - msg = fmt.Sprintf("Version %s of Aminal is now available.", strings.Replace(latestVersion, "v", "", -1)) - } - gui.textbox( - 2, - uint16(h-3), - fmt.Sprintf("%s (%d)", msg, 10-int(time.Since(startTime).Seconds())), - [3]float32{1, 1, 1}, - [3]float32{0, 0.5, 0}, - ) - } else { - showMessage = false - } - } - - gui.SwapBuffers() + gui.terminal.GetLogicalCursorX(), + gui.terminal.GetLogicalCursorY(), + gui.terminal.ActiveBuffer().ViewWidth(), + gui.terminal.ActiveBuffer().ViewHeight(), + gui.terminal.ActiveBuffer().Height(), + ), + [3]float32{1, 1, 1}, + [3]float32{0.8, 0, 0}, + ) } + if showMessage { + if latestVersion != "" && time.Since(startTime) < time.Second*10 && gui.terminal.ActiveBuffer().RawLine() == 0 { + time.AfterFunc(time.Second, gui.terminal.NotifyDirty) + _, h := gui.terminal.GetSize() + var msg string + if version.Version == "" { + msg = "You are using a development build of Aminal." + } else { + msg = fmt.Sprintf("Version %s of Aminal is now available.", strings.Replace(latestVersion, "v", "", -1)) + } + gui.textbox( + 2, + uint16(h-3), + fmt.Sprintf("%s (%d)", msg, 10-int(time.Since(startTime).Seconds())), + [3]float32{1, 1, 1}, + [3]float32{0, 0.5, 0}, + ) + } else { + showMessage = false + } + } + + gui.SwapBuffers() + glfw.WaitEvents() // Go to sleep until next event. + + // Process any terminal events since the last wakeup. + terminalEvents: + for { + select { + case <-titleChan: + gui.window.SetTitle(gui.terminal.GetTitle()) + case <-resizeChan: + gui.resizeToTerminal() + case reverse := <-reverseChan: + gui.generateDefaultCell(reverse) + default: + break terminalEvents + } + } } + close(stop) // Tell waker to end. + gui.logger.Debugf("Stopping render...") return nil } +// waker is a goroutine which listens to the terminal's dirty channel, +// waking up the main thread when the GUI needs to be +// redrawn. Limiting is applied on wakeups to avoid excessive CPU +// usage when the terminal is being updated rapidly. +func (gui *GUI) waker(stop <-chan struct{}) { + dirty := gui.terminal.Dirty() + var nextWake <-chan time.Time + var last time.Time + for { + select { + case <-dirty: + if nextWake == nil { + if time.Since(last) > wakePeriod { + // There hasn't been a wakeup recently so schedule + // the next one sooner. + nextWake = time.After(halfWakePeriod) + } else { + nextWake = time.After(wakePeriod) + } + } + case last = <-nextWake: + // TODO(mjs) - This is somewhat of a voodoo sleep but it + // avoid various rendering issues on Windows in some + // situations. Suspect that this will become unnecessary + // once various goroutine synchronisation issues have been + // resolved. + time.Sleep(halfWakePeriod) + + glfw.PostEmptyEvent() + nextWake = nil + case <-stop: + return + } + } +} + func (gui *GUI) renderTerminalData(shouldLock bool) { if shouldLock { gui.terminal.Lock() diff --git a/gui/overlays.go b/gui/overlays.go index b792fa3..d98653e 100644 --- a/gui/overlays.go +++ b/gui/overlays.go @@ -5,8 +5,8 @@ type overlay interface { } func (gui *GUI) setOverlay(m overlay) { - defer gui.terminal.SetDirtyLocked() gui.overlay = m + gui.terminal.NotifyDirty() } func (gui *GUI) renderOverlay() { diff --git a/terminal/notify.go b/terminal/notify.go new file mode 100644 index 0000000..96b14e1 --- /dev/null +++ b/terminal/notify.go @@ -0,0 +1,19 @@ +package terminal + +func newNotifier() *notifier { + return ¬ifier{ + C: make(chan struct{}, 1), + } +} + +type notifier struct { + C chan struct{} +} + +// Notify is used to signal an event in a non-blocking way. +func (n *notifier) Notify() { + select { + case n.C <- struct{}{}: + default: + } +} diff --git a/terminal/output.go b/terminal/output.go index 3425c9f..4bec2a3 100644 --- a/terminal/output.go +++ b/terminal/output.go @@ -28,25 +28,25 @@ var runeMap = map[rune]runeHandler{ func newLineHandler(terminal *Terminal) error { terminal.ActiveBuffer().NewLine() - terminal.isDirty = true + terminal.NotifyDirty() return nil } func tabHandler(terminal *Terminal) error { terminal.ActiveBuffer().Tab() - terminal.isDirty = true + terminal.NotifyDirty() return nil } func carriageReturnHandler(terminal *Terminal) error { terminal.ActiveBuffer().CarriageReturn() - terminal.isDirty = true + terminal.NotifyDirty() return nil } func backspaceHandler(terminal *Terminal) error { terminal.ActiveBuffer().Backspace() - terminal.isDirty = true + terminal.NotifyDirty() return nil } @@ -80,16 +80,16 @@ func (terminal *Terminal) processRuneLocked(b rune) { } func (terminal *Terminal) processRune(b rune) { + defer terminal.NotifyDirty() + if handler, ok := runeMap[b]; ok { if err := handler(terminal); err != nil { terminal.logger.Errorf("Error handling control code: %s", err) } - terminal.isDirty = true return } //terminal.logger.Debugf("Received character 0x%X: %q", b, string(b)) terminal.ActiveBuffer().Write(terminal.translateRune(b)) - terminal.isDirty = true } func (terminal *Terminal) translateRune(b rune) rune { @@ -129,7 +129,7 @@ func (terminal *Terminal) processInput(pty chan rune) { if err := ansiHandler(pty, terminal); err != nil { terminal.logger.Errorf("Error handling escape sequence: %s", err) } - terminal.SetDirtyLocked() + terminal.NotifyDirty() continue } diff --git a/terminal/terminal.go b/terminal/terminal.go index f9c6ce0..1c50074 100644 --- a/terminal/terminal.go +++ b/terminal/terminal.go @@ -50,12 +50,12 @@ type Terminal struct { mouseMode MouseMode mouseExtMode MouseExtMode bracketedPasteMode bool - isDirty bool charWidth float32 charHeight float32 lastBuffer uint8 terminalState *buffer.TerminalState platformDependentSettings platform.PlatformDependentSettings + dirty *notifier } type Modes struct { @@ -85,15 +85,28 @@ func New(pty platform.Pty, logger *zap.SugaredLogger, config *config.Config) *Te ShowCursor: true, }, platformDependentSettings: pty.GetPlatformDependentSettings(), + dirty: newNotifier(), } t.buffers = []*buffer.Buffer{ - buffer.NewBuffer(t.terminalState), - buffer.NewBuffer(t.terminalState), - buffer.NewBuffer(t.terminalState), + buffer.NewBuffer(t.terminalState, t.dirty), + buffer.NewBuffer(t.terminalState, t.dirty), + buffer.NewBuffer(t.terminalState, t.dirty), } t.activeBuffer = t.buffers[0] - return t + return t +} + +// Dirty returns a channel that receives an empty struct whenever the +// terminal becomes dirty. +func (terminal *Terminal) Dirty() <-chan struct{} { + return terminal.dirty.C +} + +// NotifyDirty is used to signal that the terminal is dirty and the +// screen must be redrawn. +func (terminal *Terminal) NotifyDirty() { + terminal.dirty.Notify() } func (terminal *Terminal) SetProgram(program uint32) { @@ -104,19 +117,6 @@ func (terminal *Terminal) SetBracketedPasteMode(enabled bool) { terminal.bracketedPasteMode = enabled } -func (terminal *Terminal) CheckDirty() bool { - terminal.Lock() - defer terminal.Unlock() - - d := terminal.isDirty - terminal.isDirty = false - return d || terminal.ActiveBuffer().IsDirty() -} - -func (terminal *Terminal) SetDirty() { - terminal.isDirty = true -} - func (terminal *Terminal) IsApplicationCursorKeysModeEnabled() bool { return terminal.modes.ApplicationCursorKeys } @@ -174,7 +174,7 @@ func (terminal *Terminal) GetScrollOffset() uint { } func (terminal *Terminal) ScreenScrollDown(lines uint16) { - defer terminal.SetDirty() + defer terminal.NotifyDirty() buffer := terminal.ActiveBuffer() if buffer.Height() < int(buffer.ViewHeight()) { @@ -202,7 +202,7 @@ func (terminal *Terminal) AreaScrollDown(lines uint16) { } func (terminal *Terminal) ScreenScrollUp(lines uint16) { - defer terminal.SetDirty() + defer terminal.NotifyDirty() buffer := terminal.ActiveBuffer() if buffer.Height() < int(buffer.ViewHeight()) { @@ -226,8 +226,8 @@ func (terminal *Terminal) ScrollPageUp() { } func (terminal *Terminal) ScrollToEnd() { - defer terminal.SetDirty() terminal.terminalState.SetScrollOffset(0) + terminal.NotifyDirty() } func (terminal *Terminal) GetVisibleLines() []buffer.Line { @@ -301,6 +301,7 @@ func (terminal *Terminal) GetTitle() string { func (terminal *Terminal) SetTitle(title string) { terminal.title = title terminal.emitTitleChange() + terminal.NotifyDirty() } // Write sends data, i.e. locally typed keystrokes to the pty @@ -378,6 +379,7 @@ func (terminal *Terminal) SetSize(newCols uint, newLines uint) error { terminal.ActiveBuffer().ResizeView(terminal.size.Width, terminal.size.Height) terminal.emitResize() + terminal.NotifyDirty() return nil } @@ -424,6 +426,7 @@ func (terminal *Terminal) SetScreenMode(enabled bool) { buffer.ReverseVideo() } terminal.emitReverse(enabled) + terminal.NotifyDirty() } func (terminal *Terminal) Lock() {