From 8f183ba4406d708be9b0dd49a0a5b562fe078c09 Mon Sep 17 00:00:00 2001 From: Menno Finlay-Smits Date: Tue, 12 Mar 2019 09:57:41 +1300 Subject: [PATCH] Avoid polling in main GUI loop (#245) Instead of waking up regularly WaitEventsTimeout, WaitEvents and PostEmptyEvent are used to make the main loop purely event driven. The rate of redraws due to terminal activity can be tweaked via the new wakePeriod const. This leads to some significant performance improvements: Aminal now consumes: - no CPU when idle (previously ~2.7% on my laptop) - ~8.5% CPU on my machine when running htop full screen on a large monitor (previously ~18.5% on my laptop) - scrolling large amounts of output is an order of magnitude faster This change also incidentally fixes data races around the terminal dirty flag (which is now gone). --- buffer/buffer.go | 69 +++++++---------- buffer/buffer_test.go | 171 +++++++++++++++++++++++++++--------------- gui/actions.go | 2 +- gui/gui.go | 159 +++++++++++++++++++++++++-------------- gui/overlays.go | 2 +- terminal/notify.go | 19 +++++ terminal/output.go | 14 ++-- terminal/terminal.go | 45 +++++------ 8 files changed, 291 insertions(+), 190 deletions(-) create mode 100644 terminal/notify.go 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() {