From 8f183ba4406d708be9b0dd49a0a5b562fe078c09 Mon Sep 17 00:00:00 2001 From: Menno Finlay-Smits Date: Tue, 12 Mar 2019 09:57:41 +1300 Subject: [PATCH 1/9] 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() { From 44233f384e59c74b54bc2e6ca5e7d2c3c03db9de Mon Sep 17 00:00:00 2001 From: Menno Finlay-Smits Date: Tue, 12 Mar 2019 19:46:59 +1300 Subject: [PATCH 2/9] Fix merge issues in develop (#257) * Fix merge issues in develop * Add -timeout to go test The tests are timing out on TravisCI for some reason so a timeout has been added so that `go test` will dump out the goroutine stack traces when the tests are stuck. * gui.Close now wakes up main loop Without this, otherwise successful tests in main_test.go hang. * Use a longer timeout in case CI infrastructure is slow * Fix various gofmt issues Not sure how some of these crept in. --- Makefile | 2 +- buffer/buffer.go | 2 +- gui/gui.go | 3 ++- gui/input.go | 2 +- terminal/terminal.go | 11 +---------- windows/installer/installer.go | 30 +++++++++++++++--------------- windows/launcher/launcher.go | 6 +++--- windows/winutil/winutil.go | 2 +- 8 files changed, 25 insertions(+), 33 deletions(-) diff --git a/Makefile b/Makefile index 3861ce8..6de5891 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ build: .PHONY: test test: - go test -v ./... + go test -v -timeout=8m ./... go vet -v .PHONY: check-gofmt diff --git a/buffer/buffer.go b/buffer/buffer.go index 57a7b35..201931a 100644 --- a/buffer/buffer.go +++ b/buffer/buffer.go @@ -874,7 +874,7 @@ func (buffer *Buffer) Clear() { } func (buffer *Buffer) ReallyClear() { - defer buffer.emitDisplayChange() + defer buffer.dirty.Notify() buffer.lines = []Line{} buffer.terminalState.SetScrollOffset(0) buffer.SetPosition(0, 0) diff --git a/gui/gui.go b/gui/gui.go index 0af42eb..bc7fbaa 100644 --- a/gui/gui.go +++ b/gui/gui.go @@ -326,6 +326,7 @@ func (gui *GUI) getTermSize() (uint, uint) { func (gui *GUI) Close() { gui.window.SetShouldClose(true) + glfw.PostEmptyEvent() // wake up main loop so it notices close request } func (gui *GUI) Render() error { @@ -436,7 +437,7 @@ func (gui *GUI) Render() error { go gui.waker(stop) for !gui.window.ShouldClose() { - gui.redraw() + gui.redraw(true) if gui.showDebugInfo { gui.textbox(2, 2, fmt.Sprintf(`Cursor: %d,%d diff --git a/gui/input.go b/gui/input.go index c5caada..ec7b65e 100644 --- a/gui/input.go +++ b/gui/input.go @@ -52,7 +52,7 @@ func (gui *GUI) updateSelectionMode(mods glfw.ModifierKey) { } if gui.selectionRegionMode != mode { gui.selectionRegionMode = mode - gui.terminal.SetDirty() + gui.terminal.NotifyDirty() } } diff --git a/terminal/terminal.go b/terminal/terminal.go index 1c50074..482cdc5 100644 --- a/terminal/terminal.go +++ b/terminal/terminal.go @@ -85,7 +85,7 @@ func New(pty platform.Pty, logger *zap.SugaredLogger, config *config.Config) *Te ShowCursor: true, }, platformDependentSettings: pty.GetPlatformDependentSettings(), - dirty: newNotifier(), + dirty: newNotifier(), } t.buffers = []*buffer.Buffer{ buffer.NewBuffer(t.terminalState, t.dirty), @@ -436,12 +436,3 @@ func (terminal *Terminal) Lock() { func (terminal *Terminal) Unlock() { terminal.lock.Unlock() } - -// SetDirtyLocked sets dirty flag locking the terminal to prevent data race warnings -// @todo remove when switching to event-driven architecture -func (terminal *Terminal) SetDirtyLocked() { - terminal.Lock() - defer terminal.Unlock() - - terminal.SetDirty() -} diff --git a/windows/installer/installer.go b/windows/installer/installer.go index 1d47f4b..9aba6d7 100644 --- a/windows/installer/installer.go +++ b/windows/installer/installer.go @@ -5,18 +5,18 @@ package main import ( "bufio" "errors" - "golang.org/x/sys/windows/registry" - "os" - "os/user" - "strings" - "path/filepath" - "github.com/liamg/aminal/windows/winutil" - "github.com/liamg/aminal/generated-src/installer/data" - "text/template" - "io/ioutil" - "os/exec" - "syscall" "flag" + "github.com/liamg/aminal/generated-src/installer/data" + "github.com/liamg/aminal/windows/winutil" + "golang.org/x/sys/windows/registry" + "io/ioutil" + "os" + "os/exec" + "os/user" + "path/filepath" + "strings" + "syscall" + "text/template" ) const Version = "VERSION" @@ -116,7 +116,7 @@ func createRegistryKeysForUninstaller(installDir string, isUserInstall bool) { func updateVersionInRegistry(isUserInstall bool) { regRoot := getRegistryRoot(isUserInstall) updateKey := `Software\Aminal\Update\Clients\` + ProductId - writeRegStr(regRoot, updateKey, "pv", Version + ".0") + writeRegStr(regRoot, updateKey, "pv", Version+".0") writeRegStr(regRoot, updateKey, "name", "Aminal") } @@ -128,7 +128,7 @@ func getRegistryRoot(isUserInstall bool) registry.Key { } func writeRegStr(regRoot registry.Key, keyPath string, valueName string, value string) { - const mode = registry.WRITE|registry.WOW64_32KEY + const mode = registry.WRITE | registry.WOW64_32KEY key, _, err := registry.CreateKey(regRoot, keyPath, mode) check(err) defer key.Close() @@ -154,7 +154,7 @@ func getStartMenuDir(isUserInstall bool) string { func createShortcut(linkPath, targetPath string) { type Shortcut struct { - LinkPath string + LinkPath string TargetPath string } tmpl := template.New("createLnk.vbs") @@ -215,4 +215,4 @@ func check(e error) { if e != nil { panic(e) } -} \ No newline at end of file +} diff --git a/windows/launcher/launcher.go b/windows/launcher/launcher.go index 73da185..cd5cc5f 100644 --- a/windows/launcher/launcher.go +++ b/windows/launcher/launcher.go @@ -19,6 +19,7 @@ package main import ( "errors" "fmt" + "github.com/liamg/aminal/windows/winutil" "io/ioutil" "os" "os/exec" @@ -26,12 +27,11 @@ import ( "sort" "strconv" "strings" - "github.com/liamg/aminal/windows/winutil" ) type Version struct { number [3]int - name string + name string } type Versions []Version @@ -121,4 +121,4 @@ func check(e error) { if e != nil { panic(e) } -} \ No newline at end of file +} diff --git a/windows/winutil/winutil.go b/windows/winutil/winutil.go index 99e7c5b..7536aaa 100644 --- a/windows/winutil/winutil.go +++ b/windows/winutil/winutil.go @@ -24,4 +24,4 @@ func GetExecutablePath() (string, error) { return "", e1 } return string(utf16.Decode(b[0:n])), nil -} \ No newline at end of file +} From 05c45c08927735dc485806eeffe21f49baa172d1 Mon Sep 17 00:00:00 2001 From: nikitar020 <42252263+nikitar020@users.noreply.github.com> Date: Wed, 13 Mar 2019 14:16:32 +0200 Subject: [PATCH 3/9] Implement window manipulation CSI sequences (#256) * Implement window manipulation CSI sequences * Fix travis config to make gofmt only for go1.11 builds * remove commented out functions --- .travis.yml | 2 +- glfont/truetype.go | 2 +- gui/gui.go | 17 ++++ gui/windowManipulationImpl.go | 107 +++++++++++++++++++++++ main.go | 2 + terminal/csi.go | 4 - terminal/terminal.go | 16 ++++ terminal/windowManipulation.go | 153 +++++++++++++++++++++++++++++++++ 8 files changed, 297 insertions(+), 6 deletions(-) create mode 100644 gui/windowManipulationImpl.go create mode 100644 terminal/windowManipulation.go diff --git a/.travis.yml b/.travis.yml index c30aaba..64fe1cf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -29,7 +29,7 @@ script: - if [[ $TRAVIS_OS_NAME == 'osx' ]]; then make build-darwin-native-travis; fi - if [[ $TRAVIS_OS_NAME == 'linux' ]]; then make build-linux-travis; fi - if [[ $TRAVIS_OS_NAME == 'linux' ]]; then make windows-cross-compile-travis; fi -- if [[ $TRAVIS_OS_NAME == 'linux' ]]; then make check-gofmt; fi +- if [[ $TRAVIS_OS_NAME == 'linux' && $TRAVIS_GO_VERSION =~ ^1\.11\. ]]; then echo 'check-gofmt'; make check-gofmt; fi env: global: - secure: "pdRpTOGQSUgbC9tK37voxUYJHMWDPJEmdMhNBsljpP9VnxxbR6JEFwvOQEmUHGlsYv8jma6a17jE60ngVQk8QP12cPh48i2bdbVgym/zTUOKFawCtPAzs8i7evh0di5eZ3uoyc42kG4skc+ePuVHbXC8jDxwaPpMqSHD7QyQc1/6ckI9LLkyWUqhnJJXkVwhmI74Aa1Im6QhywAWFMeTBRRL02cwr6k7VKSYOn6yrtzJRCALFGpZ/n58lPrpDxN7W8o+HRQP89wIDy8FyNeEPdmqGFNfMHDvI3oJRN4dGC4H9EkKf/iGuNJia1Bs+MgaG9kKlMHsI6Fkh5uw9KNTvC1llx43VRQJzm26cn1CpRxxRtF4F8lqkpY4tHjxxCitV+98ddW8jdmQYyx+LeueC5wqlO9g2M5L3oXsGMqZ++mDRDa8oQoQAVUSVtimeO8ODXFuVNR8TlupP0Cthgucil63VUZfAD8EHc2zpRSFxfYByDH53uMEinn20uovL6W42fqgboC43HOnR6aVfSANPsBFDlcpZFa2BY5RkcKyYdaLkucy0DKJ946UDfhOu6FNm0GPHq5HcgWkLojNF0dEFgG6J+SGQGiPjxTlHP/zoe61qMlWu+fYRXQnKWZN5Kk0T1TbAk6pKSE6wRLG8ddxvMg+eVpGLT+gAvQdrrkMFvs=" diff --git a/glfont/truetype.go b/glfont/truetype.go index c7856a2..57af926 100644 --- a/glfont/truetype.go +++ b/glfont/truetype.go @@ -48,7 +48,7 @@ func LoadTrueTypeFont(program uint32, r io.Reader, scale float32) (*Font, error) gl.BindVertexArray(f.vao) gl.BindBuffer(gl.ARRAY_BUFFER, f.vbo) - gl.BufferData(gl.ARRAY_BUFFER, 6*4*4, nil, gl.STATIC_DRAW) + gl.BufferData(gl.ARRAY_BUFFER, 6*4*4, nil, gl.DYNAMIC_DRAW) vertAttrib := uint32(gl.GetAttribLocation(f.program, gl.Str("vert\x00"))) gl.EnableVertexAttribArray(vertAttrib) diff --git a/gui/gui.go b/gui/gui.go index bc7fbaa..be228f5 100644 --- a/gui/gui.go +++ b/gui/gui.go @@ -65,6 +65,8 @@ type GUI struct { mouseMovedAfterSelectionStarted bool internalResize bool selectionRegionMode buffer.SelectionRegionMode + + mainThreadFunc chan func() } func Min(x, y int) int { @@ -167,6 +169,8 @@ func New(config *config.Config, terminal *terminal.Terminal, logger *zap.Sugared keyboardShortcuts: shortcuts, resizeLock: &sync.Mutex{}, internalResize: false, + + mainThreadFunc: make(chan func()), }, nil } @@ -490,6 +494,8 @@ Buffer Size: %d lines gui.resizeToTerminal() case reverse := <-reverseChan: gui.generateDefaultCell(reverse) + case funcForMainThread := <-gui.mainThreadFunc: + funcForMainThread() default: break terminalEvents } @@ -825,3 +831,14 @@ func (gui *GUI) windowPosChangeCallback(w *glfw.Window, xpos int, ypos int) { func (gui *GUI) monitorChangeCallback(monitor *glfw.Monitor, event glfw.MonitorEvent) { gui.SetDPIScale() } + +// Synchronously executes the argument function in the main thread. +// Does not return until f() executed! +func (gui *GUI) executeInMainThread(f func() error) error { + resultChan := make(chan error, 1) + gui.mainThreadFunc <- func() { + resultChan <- f() + } + gui.terminal.NotifyDirty() // wake up the main thread to allow processing + return <-resultChan +} diff --git a/gui/windowManipulationImpl.go b/gui/windowManipulationImpl.go new file mode 100644 index 0000000..e6c6459 --- /dev/null +++ b/gui/windowManipulationImpl.go @@ -0,0 +1,107 @@ +package gui + +import ( + "fmt" + "github.com/go-gl/glfw/v3.2/glfw" + "github.com/liamg/aminal/terminal" +) + +// +// Implementation of the terminal.WindowManipulationInterface +// + +func (gui *GUI) RestoreWindow(term *terminal.Terminal) error { + return gui.executeInMainThread(func() error { + gui.window.Restore() + return nil + }) +} + +func (gui *GUI) IconifyWindow(term *terminal.Terminal) error { + return gui.executeInMainThread(func() error { + return gui.window.Iconify() + }) +} + +func (gui *GUI) MoveWindow(term *terminal.Terminal, pixelX int, pixelY int) error { + return gui.executeInMainThread(func() error { + gui.window.SetPos(pixelX, pixelY) + return nil + }) +} + +func (gui *GUI) ResizeWindowByPixels(term *terminal.Terminal, pixelsHeight int, pixelsWidth int) error { + return gui.executeInMainThread(func() error { + term.Unlock() + gui.window.SetSize(pixelsWidth, pixelsHeight) + term.Lock() + return nil + }) +} + +func (gui *GUI) BringWindowToFront(term *terminal.Terminal) error { + var err error + if gui.window.GetAttrib(glfw.Iconified) != 0 { + err = gui.executeInMainThread(func() error { + return gui.window.Restore() + }) + } + + if err != nil { + err = gui.window.Focus() + } + + return err +} + +func (gui *GUI) ResizeWindowByChars(term *terminal.Terminal, charsHeight int, charsWidth int) error { + return gui.executeInMainThread(func() error { + return term.SetSize(uint(charsWidth), uint(charsHeight)) + }) +} + +func (gui *GUI) MaximizeWindow(term *terminal.Terminal) error { + return gui.executeInMainThread(func() error { + term.Lock() + err := gui.window.Maximize() + term.Unlock() + return err + }) +} + +func (gui *GUI) ReportWindowState(term *terminal.Terminal) error { + // Report xterm window state. If the xterm window is open (non-iconified), it returns CSI 1 t . + // If the xterm window is iconified, it returns CSI 2 t . + if gui.window.GetAttrib(glfw.Iconified) != 0 { + _ = term.Write([]byte("\x1b[2t")) + } else { + _ = term.Write([]byte("\x1b[1t")) + } + + return nil +} + +func (gui *GUI) ReportWindowPosition(term *terminal.Terminal) error { + // Report xterm window position as CSI 3 ; x; yt + x, y := gui.window.GetPos() + + _ = term.Write([]byte(fmt.Sprintf("\x1b[3;%d;%dt", x, y))) + + return nil +} + +func (gui *GUI) ReportWindowSizeInPixels(term *terminal.Terminal) error { + // Report xterm window in pixels as CSI 4 ; height ; width t + _ = term.Write([]byte(fmt.Sprintf("\x1b[4;%d;%dt", gui.height, gui.width))) + + return nil +} + +func (gui *GUI) ReportWindowSizeInChars(term *terminal.Terminal) error { + // Report the size of the text area in characters as CSI 8 ; height ; width t + charsWidth, charsHeight := gui.renderer.GetTermSize() + + _ = term.Write([]byte(fmt.Sprintf("\x1b[8;%d;%dt", charsHeight, charsWidth))) + + return nil +} diff --git a/main.go b/main.go index 0cd8fa0..99dc6a6 100644 --- a/main.go +++ b/main.go @@ -71,6 +71,8 @@ func initialize(unitTestfunc callback) { logger.Fatalf("Cannot start: %s", err) } + terminal.WindowManipulation = g + if unitTestfunc != nil { go unitTestfunc(terminal, g) } else { diff --git a/terminal/csi.go b/terminal/csi.go index 6a0e3f8..63275b2 100644 --- a/terminal/csi.go +++ b/terminal/csi.go @@ -425,10 +425,6 @@ func csiSetModeHandler(params []string, terminal *Terminal) error { return csiSetModes(params, true, terminal) } -func csiWindowManipulation(params []string, terminal *Terminal) error { - return fmt.Errorf("Window manipulation is not yet supported") -} - func csiLinePositionAbsolute(params []string, terminal *Terminal) error { row := 1 if len(params) > 0 { diff --git a/terminal/terminal.go b/terminal/terminal.go index 482cdc5..170031c 100644 --- a/terminal/terminal.go +++ b/terminal/terminal.go @@ -33,6 +33,20 @@ const ( MouseExtSGR ) +type WindowManipulationInterface interface { + RestoreWindow(term *Terminal) error + IconifyWindow(term *Terminal) error + MoveWindow(term *Terminal, pixelX int, pixelY int) error + ResizeWindowByPixels(term *Terminal, pixelsHeight int, pixelsWidth int) error + BringWindowToFront(term *Terminal) error + ResizeWindowByChars(term *Terminal, charsHeight int, charsWidth int) error + MaximizeWindow(term *Terminal) error + ReportWindowState(term *Terminal) error + ReportWindowPosition(term *Terminal) error + ReportWindowSizeInPixels(term *Terminal) error + ReportWindowSizeInChars(term *Terminal) error +} + type Terminal struct { program uint32 buffers []*buffer.Buffer @@ -56,6 +70,8 @@ type Terminal struct { terminalState *buffer.TerminalState platformDependentSettings platform.PlatformDependentSettings dirty *notifier + + WindowManipulation WindowManipulationInterface } type Modes struct { diff --git a/terminal/windowManipulation.go b/terminal/windowManipulation.go new file mode 100644 index 0000000..d604b07 --- /dev/null +++ b/terminal/windowManipulation.go @@ -0,0 +1,153 @@ +package terminal + +import ( + "fmt" + "strconv" +) + +func getOptionalIntegerParam(params []string, paramNo int, defValue int) (int, error) { + result := defValue + if len(params) >= paramNo+1 { + var err error + result, err = strconv.Atoi(params[paramNo]) + if err != nil { + return defValue, err + } + } + + return result, nil +} + +func getMandatoryIntegerParam(params []string, paramNo int) (int, error) { + if len(params) < paramNo+1 { + return 0, fmt.Errorf("no mandatory parameter") + } + + result, err := strconv.Atoi(params[paramNo]) + if err != nil { + return 0, err + } + + return result, nil +} + +func csiWindowManipulation(params []string, terminal *Terminal) error { + if terminal.WindowManipulation == nil { + return fmt.Errorf("Handler for CSI window manipulation commands is not set") + } + + operation, err := getMandatoryIntegerParam(params, 0) + if err != nil { + return fmt.Errorf("CSI t ignored: %s", err.Error()) + } + + switch operation { + case 1: + terminal.logger.Debug("De-iconify window") + return terminal.WindowManipulation.RestoreWindow(terminal) + + case 2: + terminal.logger.Debug("Iconify window") + return terminal.WindowManipulation.IconifyWindow(terminal) + + case 3: + terminal.logger.Debug("Move window") + { + x, err := getMandatoryIntegerParam(params, 1) + if err != nil { + return err + } + y, err := getMandatoryIntegerParam(params, 2) + if err != nil { + return err + } + + return terminal.WindowManipulation.MoveWindow(terminal, x, y) + } + + case 4: + terminal.logger.Debug("Resize the window in pixels") + { + height, err := getMandatoryIntegerParam(params, 1) + if err != nil { + return err + } + width, err := getMandatoryIntegerParam(params, 2) + if err != nil { + return err + } + + return terminal.WindowManipulation.ResizeWindowByPixels(terminal, height, width) + } + + case 5: + terminal.logger.Debug("Raise the window to the front") + return terminal.WindowManipulation.BringWindowToFront(terminal) + + case 6: + return fmt.Errorf("Lowering the window to the bottom is not implemented") + + case 7: + // NB: On Windows this sequence seem handled by the system + return fmt.Errorf("Refreshing the window is not implemented") + + case 8: + terminal.logger.Debug("Resize the text area in characters") + { + height, err := getMandatoryIntegerParam(params, 1) + if err != nil { + return err + } + width, err := getMandatoryIntegerParam(params, 2) + if err != nil { + return err + } + return terminal.WindowManipulation.ResizeWindowByChars(terminal, height, width) + } + + case 9: + { + p, err := getMandatoryIntegerParam(params, 1) + if err != nil { + return err + } + if p == 0 { + terminal.logger.Debug("Restore maximized window") + return terminal.WindowManipulation.RestoreWindow(terminal) + } else if p == 1 { + terminal.logger.Debug("Maximize window") + return terminal.WindowManipulation.MaximizeWindow(terminal) + } + } + + case 11: + terminal.logger.Debug("Report the window state") + return terminal.WindowManipulation.ReportWindowState(terminal) + + case 13: + terminal.logger.Debug("Report the window position") + return terminal.WindowManipulation.ReportWindowPosition(terminal) + + case 14: + terminal.logger.Debug("Report the window size in pixels") + return terminal.WindowManipulation.ReportWindowSizeInPixels(terminal) + + case 18: + terminal.logger.Debug("Report the window size in characters as CSI 8") + return terminal.WindowManipulation.ReportWindowSizeInChars(terminal) + + case 19: + return fmt.Errorf("Reporting the screen size in characters is not implemented") + + case 20: + return fmt.Errorf("Reporting the window icon label is not implemented") + + case 21: + return fmt.Errorf("Reporting the window title is not implemented") + + default: + return fmt.Errorf("not supported CSI t") + } + + return nil +} From 4710948cd131a12cda121f18fc9c6e0456a923d8 Mon Sep 17 00:00:00 2001 From: Menno Finlay-Smits Date: Thu, 14 Mar 2019 02:57:08 +1300 Subject: [PATCH 4/9] Decouple vttest tests from user's config (#258) * Make DefaultConfig safer Instead of having a global mutable DefaultConfig which might be changed by anything during run/test time turn DefaultConfig into a function which returns a fresh DefaultConfig. This is safer and more convenient. * Decouple vttest tests from user's config The screen capture tests were failing on my machine because the screen capture based vttest tests were using my personal config in ~/.config/aminal/config.toml. This had different colours and a fixed DPI scaling factor which mean the screen captures didn't match. The sixel tests were also failing because my login shell is a highly customised zsh. A static test config is now passed by the vttest tests and the shell is set to "/bin/sh" on Linux, OSX etc to help avoid problems due to differences between shells and shell configs. --- config.go | 17 +++++++--- config/config.go | 6 ++-- config/defaults.go | 78 ++++++++++++++++++++++------------------------ main.go | 8 +++-- main_test.go | 19 +++++++++-- 5 files changed, 74 insertions(+), 54 deletions(-) diff --git a/config.go b/config.go index b0c54c0..3db76bf 100644 --- a/config.go +++ b/config.go @@ -22,6 +22,13 @@ func getActuallyProvidedFlags() map[string]bool { return result } +func maybeGetConfig(override *config.Config) *config.Config { + if override != nil { + return override + } + return getConfig() +} + func getConfig() *config.Config { showVersion := false ignoreConfig := false @@ -53,7 +60,7 @@ func getConfig() *config.Config { var conf *config.Config if ignoreConfig { - conf = &config.DefaultConfig + conf = config.DefaultConfig() } else { conf = loadConfigFile() } @@ -83,12 +90,12 @@ func loadConfigFile() *config.Config { usr, err := user.Current() if err != nil { fmt.Printf("Failed to get current user information: %s\n", err) - return &config.DefaultConfig + return config.DefaultConfig() } home := usr.HomeDir if home == "" { - return &config.DefaultConfig + return config.DefaultConfig() } places := []string{} @@ -111,7 +118,7 @@ func loadConfigFile() *config.Config { } } - if b, err := config.DefaultConfig.Encode(); err != nil { + if b, err := config.DefaultConfig().Encode(); err != nil { fmt.Printf("Failed to encode config file: %s\n", err) } else { err = os.MkdirAll(filepath.Dir(places[0]), 0744) @@ -124,5 +131,5 @@ func loadConfigFile() *config.Config { } } - return &config.DefaultConfig + return config.DefaultConfig() } diff --git a/config/config.go b/config/config.go index 92e187f..11add72 100644 --- a/config/config.go +++ b/config/config.go @@ -24,12 +24,12 @@ type Config struct { type KeyMappingConfig map[string]string func Parse(data []byte) (*Config, error) { - c := DefaultConfig - err := toml.Unmarshal(data, &c) + c := DefaultConfig() + err := toml.Unmarshal(data, c) if c.KeyMapping == nil { c.KeyMapping = KeyMappingConfig(map[string]string{}) } - return &c, err + return c, err } func (c *Config) Encode() ([]byte, error) { diff --git a/config/defaults.go b/config/defaults.go index bc0557c..31eb279 100644 --- a/config/defaults.go +++ b/config/defaults.go @@ -2,52 +2,50 @@ package config import "runtime" -var DefaultConfig = Config{ - DebugMode: false, - ColourScheme: ColourScheme{ - Cursor: strToColourNoErr("#e8dfd6"), - Foreground: strToColourNoErr("#e8dfd6"), - Background: strToColourNoErr("#021b21"), - Black: strToColourNoErr("#000000"), - Red: strToColourNoErr("#800000"), - Green: strToColourNoErr("#008000"), - Yellow: strToColourNoErr("#808000"), - Blue: strToColourNoErr("#000080"), - Magenta: strToColourNoErr("#800080"), - Cyan: strToColourNoErr("#008080"), - LightGrey: strToColourNoErr("#f2f2f2"), - DarkGrey: strToColourNoErr("#808080"), - LightRed: strToColourNoErr("#ff0000"), - LightGreen: strToColourNoErr("#00ff00"), - LightYellow: strToColourNoErr("#ffff00"), - LightBlue: strToColourNoErr("#0000ff"), - LightMagenta: strToColourNoErr("#ff00ff"), - LightCyan: strToColourNoErr("#00ffff"), - White: strToColourNoErr("#ffffff"), - Selection: strToColourNoErr("#333366"), - }, - KeyMapping: KeyMappingConfig(map[string]string{}), - SearchURL: "https://www.google.com/search?q=$QUERY", - MaxLines: 1000, - CopyAndPasteWithMouse: true, -} - -func init() { - DefaultConfig.KeyMapping[string(ActionCopy)] = addMod("c") - DefaultConfig.KeyMapping[string(ActionPaste)] = addMod("v") - DefaultConfig.KeyMapping[string(ActionSearch)] = addMod("g") - DefaultConfig.KeyMapping[string(ActionToggleDebug)] = addMod("d") - DefaultConfig.KeyMapping[string(ActionToggleSlomo)] = addMod(";") - DefaultConfig.KeyMapping[string(ActionReportBug)] = addMod("r") - DefaultConfig.KeyMapping[string(ActionBufferClear)] = addMod("k") +func DefaultConfig() *Config { + return &Config{ + DebugMode: false, + ColourScheme: ColourScheme{ + Cursor: strToColourNoErr("#e8dfd6"), + Foreground: strToColourNoErr("#e8dfd6"), + Background: strToColourNoErr("#021b21"), + Black: strToColourNoErr("#000000"), + Red: strToColourNoErr("#800000"), + Green: strToColourNoErr("#008000"), + Yellow: strToColourNoErr("#808000"), + Blue: strToColourNoErr("#000080"), + Magenta: strToColourNoErr("#800080"), + Cyan: strToColourNoErr("#008080"), + LightGrey: strToColourNoErr("#f2f2f2"), + DarkGrey: strToColourNoErr("#808080"), + LightRed: strToColourNoErr("#ff0000"), + LightGreen: strToColourNoErr("#00ff00"), + LightYellow: strToColourNoErr("#ffff00"), + LightBlue: strToColourNoErr("#0000ff"), + LightMagenta: strToColourNoErr("#ff00ff"), + LightCyan: strToColourNoErr("#00ffff"), + White: strToColourNoErr("#ffffff"), + Selection: strToColourNoErr("#333366"), + }, + KeyMapping: KeyMappingConfig(map[string]string{ + string(ActionCopy): addMod("c"), + string(ActionPaste): addMod("v"), + string(ActionSearch): addMod("g"), + string(ActionToggleDebug): addMod("d"), + string(ActionToggleSlomo): addMod(";"), + string(ActionReportBug): addMod("r"), + string(ActionBufferClear): addMod("k"), + }), + SearchURL: "https://www.google.com/search?q=$QUERY", + MaxLines: 1000, + CopyAndPasteWithMouse: true, + } } func addMod(keys string) string { standardMod := "ctrl + shift + " - if runtime.GOOS == "darwin" { standardMod = "super + " } - return standardMod + keys } diff --git a/main.go b/main.go index 99dc6a6..1b4114c 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "runtime" "runtime/pprof" + "github.com/liamg/aminal/config" "github.com/liamg/aminal/gui" "github.com/liamg/aminal/platform" "github.com/liamg/aminal/terminal" @@ -20,11 +21,12 @@ func init() { } func main() { - initialize(nil) + initialize(nil, nil) } -func initialize(unitTestfunc callback) { - conf := getConfig() +func initialize(unitTestfunc callback, configOverride *config.Config) { + conf := maybeGetConfig(configOverride) + logger, err := getLogger(conf) if err != nil { fmt.Printf("Failed to create logger: %s\n", err) diff --git a/main_test.go b/main_test.go index 5ef9b88..ab91005 100644 --- a/main_test.go +++ b/main_test.go @@ -6,10 +6,12 @@ import ( "flag" "fmt" "os" + "runtime" "strings" "testing" "time" + "github.com/liamg/aminal/config" "github.com/liamg/aminal/gui" "github.com/liamg/aminal/terminal" @@ -123,7 +125,7 @@ func TestCursorMovement(t *testing.T) { g.Close() } - initialize(testFunc) + initialize(testFunc, testConfig()) }) } @@ -159,7 +161,7 @@ func TestScreenFeatures(t *testing.T) { g.Close() } - initialize(testFunc) + initialize(testFunc, testConfig()) }) } @@ -184,7 +186,7 @@ func TestSixel(t *testing.T) { g.Close() } - initialize(testFunc) + initialize(testFunc, testConfig()) }) } @@ -192,3 +194,14 @@ func TestSixel(t *testing.T) { func TestExit(t *testing.T) { os.Exit(0) } + +func testConfig() *config.Config { + c := config.DefaultConfig() + + // Use a vanilla shell on POSIX to help ensure consistency. + if runtime.GOOS != "windows" { + c.Shell = "/bin/sh" + } + + return c +} From 4f308bdbd8a9d8535b7ba3deb2981a3fee36b64a Mon Sep 17 00:00:00 2001 From: nikitar020 <42252263+nikitar020@users.noreply.github.com> Date: Thu, 14 Mar 2019 11:24:39 +0200 Subject: [PATCH 5/9] Add running gofmt to the PR template (#259) * Update CODEOWNERS * Add running gofmt to the PR template --- PULL_REQUEST_TEMPLATE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md index c6a8195..fde5165 100644 --- a/PULL_REQUEST_TEMPLATE.md +++ b/PULL_REQUEST_TEMPLATE.md @@ -29,6 +29,7 @@ Please describe the tests that you ran to verify your changes. Provide instructi ## Checklist: +- [ ] I have run `gofmt` on the project - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas From 000e763a61cd36a896d5eea8adc2079fa90207e5 Mon Sep 17 00:00:00 2001 From: nikitar020 <42252263+nikitar020@users.noreply.github.com> Date: Mon, 18 Mar 2019 17:49:37 +0200 Subject: [PATCH 6/9] Make shells running on Windows (#260) --- platform/winpty.go | 43 ++----------------------------------------- 1 file changed, 2 insertions(+), 41 deletions(-) diff --git a/platform/winpty.go b/platform/winpty.go index 4bc1dc2..386ec79 100644 --- a/platform/winpty.go +++ b/platform/winpty.go @@ -4,12 +4,8 @@ package platform import ( "errors" - "syscall" - "time" - - "fmt" - "github.com/MaxRis/w32" + "syscall" ) // #include "windows.h" @@ -144,42 +140,7 @@ func (pty *winConPty) CreateGuestProcess(imagePath string) (Process, error) { pty.processID = process.processID - err = setupChildConsole(C.DWORD(process.processID), C.STD_OUTPUT_HANDLE, C.ENABLE_PROCESSED_OUTPUT|C.ENABLE_WRAP_AT_EOL_OUTPUT) - if err != nil { - process.Close() - return nil, err - } - - return process, err -} - -func setupChildConsole(processID C.DWORD, nStdHandle C.DWORD, mode uint) error { - C.FreeConsole() - defer C.AttachConsole(^C.DWORD(0)) // attach to parent process console - - // process may not be ready so we'll do retries - const maxWaitMilliSeconds = 5000 - const waitStepMilliSeconds = 200 - count := maxWaitMilliSeconds / waitStepMilliSeconds - - for { - if r := C.AttachConsole(processID); r != 0 { - break // success - } - lastError := C.GetLastError() - if lastError != C.ERROR_GEN_FAILURE || count <= 0 { - return fmt.Errorf("Was not able to attach to the child prosess' console") - } - - time.Sleep(time.Millisecond * time.Duration(waitStepMilliSeconds)) - count-- - } - - h := C.GetStdHandle(nStdHandle) - C.SetConsoleMode(h, C.DWORD(mode)) - C.FreeConsole() - - return nil + return process, nil } func (pty *winConPty) Resize(x, y int) error { From c2a7be2aeb726a706c600b649b3657bcd996c47a Mon Sep 17 00:00:00 2001 From: nikitar020 <42252263+nikitar020@users.noreply.github.com> Date: Tue, 19 Mar 2019 19:57:17 +0200 Subject: [PATCH 7/9] Implementation of the vertical scrollbar (#229) * Implementation of the vertical scrollbar (also enable gofmt checks only for go1.11.x builds) --- README.md | 33 +-- buffer/buffer.go | 15 +- config/config.go | 1 + config/defaults.go | 1 + gui/fonts.go | 10 +- gui/gui.go | 125 +++++++--- gui/mouse.go | 95 ++++++- gui/rectangleRenderer.go | 157 ++++++++++++ gui/renderer.go | 150 ++--------- gui/scrollbar.go | 519 +++++++++++++++++++++++++++++++++++++++ main.go | 1 + main_test.go | 3 + 12 files changed, 923 insertions(+), 187 deletions(-) create mode 100644 gui/rectangleRenderer.go create mode 100644 gui/scrollbar.go diff --git a/README.md b/README.md index bfe5e8a..26d14ff 100644 --- a/README.md +++ b/README.md @@ -114,28 +114,29 @@ shell = "/bin/bash" # The shell to run for the terminal session. Default search_url = "https://www.google.com/search?q=$QUERY" # The search engine to use for the "search selected text" action. Defaults to google. Set this to your own search url using $QUERY as the keywords to replace when searching. max_lines = 1000 # Maximum number of lines in the terminal buffer. copy_and_paste_with_mouse = true # Text selected with the mouse is copied to the clipboard on end selection, and is pasted on right mouse button click. +show_vertical_scrollbar = true # Whether to show the vertical scrollbar dpi-scale = 0.0 # Override DPI scale. Defaults to 0.0 (let Aminal determine the DPI scale itself). [colours] cursor = "#e8dfd6" foreground = "#e8dfd6" background = "#021b21" - black = "#032c36" - red = "#c2454e" - green = "#7cbf9e" - yellow = "#8a7a63" - blue = "#065f73" - magenta = "#ff5879" - cyan = "#44b5b1" - light_grey = "#f2f1b9" - dark_grey = "#3e4360" - light_red = "#ef5847" - light_green = "#a2db91" - light_yellow = "#beb090" - light_blue = "#61778d" - light_magenta = "#ff99a1" - light_cyan = "#9ed9d8" - white = "#f6f6c9" + black = "#000000" + red = "#800000" + green = "#008000" + yellow = "#808000" + blue = "#000080" + magenta = "#800080" + cyan = "#008080" + light_grey = "#f2f2f2" + dark_grey = "#808080" + light_red = "#ff0000" + light_green = "#00ff00" + light_yellow = "#ffff00" + light_blue = "#0000ff" + light_magenta = "#ff00ff" + light_cyan = "#00ffff" + white = "#ffffff" selection = "#333366" # Mouse selection background colour [keys] diff --git a/buffer/buffer.go b/buffer/buffer.go index 201931a..7ec9f93 100644 --- a/buffer/buffer.go +++ b/buffer/buffer.go @@ -518,6 +518,15 @@ func (buffer *Buffer) convertRawLineToViewLine(rawLine uint64) uint16 { return uint16(int(rawLine) - (rawHeight - int(buffer.terminalState.viewHeight))) } +func (buffer *Buffer) GetVPosition() int { + result := int(uint(buffer.Height()) - uint(buffer.ViewHeight()) - buffer.terminalState.scrollLinesFromBottom) + if result < 0 { + result = 0 + } + + return result +} + // Width returns the width of the buffer in columns func (buffer *Buffer) Width() uint16 { return buffer.terminalState.viewWidth @@ -545,7 +554,7 @@ func (buffer *Buffer) insertLine() { if !buffer.InScrollableRegion() { pos := buffer.RawLine() - maxLines := buffer.getMaxLines() + maxLines := buffer.GetMaxLines() newLineCount := uint64(len(buffer.lines) + 1) if newLineCount > maxLines { newLineCount = maxLines @@ -641,7 +650,7 @@ func (buffer *Buffer) Index() { if buffer.terminalState.cursorY >= buffer.ViewHeight()-1 { buffer.lines = append(buffer.lines, newLine()) - maxLines := buffer.getMaxLines() + maxLines := buffer.GetMaxLines() if uint64(len(buffer.lines)) > maxLines { copy(buffer.lines, buffer.lines[uint64(len(buffer.lines))-maxLines:]) buffer.lines = buffer.lines[:maxLines] @@ -1115,7 +1124,7 @@ func (buffer *Buffer) ResizeView(width uint16, height uint16) { buffer.terminalState.ResetVerticalMargins() } -func (buffer *Buffer) getMaxLines() uint64 { +func (buffer *Buffer) GetMaxLines() uint64 { result := buffer.terminalState.maxLines if result < uint64(buffer.terminalState.viewHeight) { result = uint64(buffer.terminalState.viewHeight) diff --git a/config/config.go b/config/config.go index 11add72..dff0785 100644 --- a/config/config.go +++ b/config/config.go @@ -14,6 +14,7 @@ type Config struct { SearchURL string `toml:"search_url"` MaxLines uint64 `toml:"max_lines"` CopyAndPasteWithMouse bool `toml:"copy_and_paste_with_mouse"` + ShowVerticalScrollbar bool `toml:"show_vertical_scrollbar"` // Developer options. DebugMode bool `toml:"debug"` diff --git a/config/defaults.go b/config/defaults.go index 31eb279..6a62c78 100644 --- a/config/defaults.go +++ b/config/defaults.go @@ -39,6 +39,7 @@ func DefaultConfig() *Config { SearchURL: "https://www.google.com/search?q=$QUERY", MaxLines: 1000, CopyAndPasteWithMouse: true, + ShowVerticalScrollbar: true, } } diff --git a/gui/fonts.go b/gui/fonts.go index 3e0aaf6..911be0e 100644 --- a/gui/fonts.go +++ b/gui/fonts.go @@ -8,14 +8,14 @@ import ( "github.com/liamg/aminal/glfont" ) -func (gui *GUI) getPackedFont(name string) (*glfont.Font, error) { +func (gui *GUI) getPackedFont(name string, actualWidth int, actualHeight int) (*glfont.Font, error) { box := packr.NewBox("./packed-fonts") fontBytes, err := box.Find(name) if err != nil { return nil, fmt.Errorf("packaged font '%s' could not be read: %s", name, err) } - font, err := glfont.LoadFont(bytes.NewReader(fontBytes), gui.fontScale*gui.dpiScale/gui.scale(), gui.width, gui.height) + font, err := glfont.LoadFont(bytes.NewReader(fontBytes), gui.fontScale*gui.dpiScale/gui.scale(), actualWidth, actualHeight) if err != nil { return nil, fmt.Errorf("font '%s' failed to load: %v", name, err) } @@ -23,16 +23,16 @@ func (gui *GUI) getPackedFont(name string) (*glfont.Font, error) { return font, nil } -func (gui *GUI) loadFonts() error { +func (gui *GUI) loadFonts(actualWidth int, actualHeight int) error { // from https://github.com/ryanoasis/nerd-fonts/tree/master/patched-fonts/Hack - defaultFont, err := gui.getPackedFont("Hack Regular Nerd Font Complete.ttf") + defaultFont, err := gui.getPackedFont("Hack Regular Nerd Font Complete.ttf", actualWidth, actualHeight) if err != nil { return err } - boldFont, err := gui.getPackedFont("Hack Bold Nerd Font Complete.ttf") + boldFont, err := gui.getPackedFont("Hack Bold Nerd Font Complete.ttf", actualWidth, actualHeight) if err != nil { return err } diff --git a/gui/gui.go b/gui/gui.go index be228f5..7c6b9af 100644 --- a/gui/gui.go +++ b/gui/gui.go @@ -30,6 +30,18 @@ import ( const wakePeriod = time.Second / 120 const halfWakePeriod = wakePeriod / 2 +const ( + DefaultWindowWidth = 800 + DefaultWindowHeight = 600 +) + +type mouseEventsHandler interface { + mouseMoveCallback(g *GUI, px float64, py float64) + mouseButtonCallback(g *GUI, button glfw.MouseButton, action glfw.Action, mod glfw.ModifierKey, mouseX float64, mouseY float64) + cursorEnterCallback(g *GUI, enter bool) + isMouseInside(px float64, py float64) bool +} + type GUI struct { window *glfw.Window logger *zap.SugaredLogger @@ -63,8 +75,15 @@ type GUI struct { leftClickTime time.Time leftClickCount int // number of clicks in a serie - single click, double click, or triple click mouseMovedAfterSelectionStarted bool - internalResize bool - selectionRegionMode buffer.SelectionRegionMode + + catchedMouseHandler mouseEventsHandler + mouseCatchedOnButton glfw.MouseButton + prevMouseEventHandler mouseEventsHandler + + internalResize bool + selectionRegionMode buffer.SelectionRegionMode + + vScrollbar *scrollbar mainThreadFunc chan func() } @@ -156,24 +175,33 @@ func New(config *config.Config, terminal *terminal.Terminal, logger *zap.Sugared } return &GUI{ - config: config, - logger: logger, - width: 800, - height: 600, - appliedWidth: 0, - appliedHeight: 0, - dpiScale: 1, - terminal: terminal, - fontScale: 10.0, - terminalAlpha: 1, - keyboardShortcuts: shortcuts, - resizeLock: &sync.Mutex{}, - internalResize: false, + config: config, + logger: logger, + width: DefaultWindowWidth, + height: DefaultWindowHeight, + appliedWidth: 0, + appliedHeight: 0, + dpiScale: 1, + terminal: terminal, + fontScale: 10.0, + terminalAlpha: 1, + keyboardShortcuts: shortcuts, + resizeLock: &sync.Mutex{}, + internalResize: false, + vScrollbar: nil, + catchedMouseHandler: nil, mainThreadFunc: make(chan func()), }, nil } +func (gui *GUI) Free() { + if gui.vScrollbar != nil { + gui.vScrollbar.Free() + gui.vScrollbar = nil + } +} + // inspired by https://kylewbanks.com/blog/tutorial-opengl-with-golang-part-1-hello-opengl func (gui *GUI) scale() float32 { @@ -208,15 +236,20 @@ func (gui *GUI) resizeToTerminal() { gui.logger.Debugf("Initiating GUI resize to columns=%d rows=%d", newCols, newRows) gui.logger.Debugf("Calculating size...") - width, height := gui.renderer.GetRectangleSize(newCols, newRows) + width, height := gui.renderer.ConvertCoordinates(newCols, newRows) roundedWidth := int(math.Ceil(float64(width))) roundedHeight := int(math.Ceil(float64(height))) + if gui.vScrollbar != nil { + roundedWidth += int(gui.vScrollbar.position.width()) + } + gui.resizeCache = &ResizeCache{roundedWidth, roundedHeight, newCols, newRows} gui.logger.Debugf("Resizing window to %dx%d", roundedWidth, roundedHeight) gui.internalResize = true + gui.window.SetSize(roundedWidth, roundedHeight) // will trigger resize() gui.internalResize = false } @@ -278,14 +311,20 @@ func (gui *GUI) resize(w *glfw.Window, width int, height int) { gui.width = width gui.height = height - gui.appliedWidth = width - gui.appliedHeight = height + gui.appliedWidth = gui.width + gui.appliedHeight = gui.height + + vScrollbarWidth := 0 + if gui.vScrollbar != nil { + gui.vScrollbar.resize(gui) + vScrollbarWidth = int(gui.vScrollbar.position.width()) + } gui.logger.Debugf("Updating font resolutions...") - gui.loadFonts() + gui.loadFonts(gui.width, gui.height) gui.logger.Debugf("Setting renderer area...") - gui.renderer.SetArea(0, 0, gui.width, gui.height) + gui.renderer.SetArea(0, 0, gui.width-vScrollbarWidth, gui.height) if gui.resizeCache != nil && gui.resizeCache.Width == width && gui.resizeCache.Height == height { gui.logger.Debugf("No need to resize internal terminal!") @@ -338,14 +377,14 @@ func (gui *GUI) Render() error { gui.logger.Debugf("Creating window...") var err error gui.window, err = gui.createWindow() - gui.SetDPIScale() - gui.window.SetSize(int(float32(gui.width)*gui.dpiScale), - int(float32(gui.height)*gui.dpiScale)) if err != nil { return fmt.Errorf("Failed to create window: %s", err) } defer glfw.Terminate() + gui.SetDPIScale() + gui.window.SetSize(int(float32(gui.width)*gui.dpiScale), int(float32(gui.height)*gui.dpiScale)) + gui.logger.Debugf("Initialising OpenGL and creating program...") program, err := gui.createProgram() if err != nil { @@ -355,8 +394,19 @@ func (gui *GUI) Render() error { gui.colourAttr = uint32(gl.GetAttribLocation(program, gl.Str("inColour\x00"))) gl.BindFragDataLocation(program, 0, gl.Str("outColour\x00")) + vScrollbarWidth := 0 + if gui.config.ShowVerticalScrollbar { + vScrollbar, err := newScrollbar() + if err != nil { + return err + } + gui.vScrollbar = vScrollbar + gui.vScrollbar.resize(gui) + vScrollbarWidth = int(gui.vScrollbar.position.width()) + } + gui.logger.Debugf("Loading font...") - if err := gui.loadFonts(); err != nil { + if err := gui.loadFonts(gui.width, gui.height); err != nil { return fmt.Errorf("Failed to load font: %s", err) } @@ -364,14 +414,18 @@ func (gui *GUI) Render() error { resizeChan := make(chan bool, 1) reverseChan := make(chan bool, 1) - gui.renderer = NewOpenGLRenderer(gui.config, gui.fontMap, 0, 0, gui.width, gui.height, gui.colourAttr, program) + gui.renderer, err = NewOpenGLRenderer(gui.config, gui.fontMap, 0, 0, gui.width-vScrollbarWidth, gui.height, gui.colourAttr, program) + if err != nil { + return err + } gui.window.SetFramebufferSizeCallback(gui.resize) gui.window.SetKeyCallback(gui.key) gui.window.SetCharCallback(gui.char) gui.window.SetScrollCallback(gui.glfwScrollCallback) - gui.window.SetMouseButtonCallback(gui.mouseButtonCallback) - gui.window.SetCursorPosCallback(gui.mouseMoveCallback) + gui.window.SetMouseButtonCallback(gui.globalMouseButtonCallback) + gui.window.SetCursorPosCallback(gui.globalMouseMoveCallback) + gui.window.SetCursorEnterCallback(gui.globalCursorEnterCallback) gui.window.SetRefreshCallback(func(w *glfw.Window) { gui.terminal.NotifyDirty() }) @@ -404,6 +458,10 @@ func (gui *GUI) Render() error { gui.Close() }() + if gui.vScrollbar != nil { + gui.vScrollbar.resize(gui) + } + gui.logger.Debugf("Starting render...") gl.UseProgram(program) @@ -677,8 +735,9 @@ func (gui *GUI) renderTerminalData(shouldLock bool) { gui.renderer.DrawUnderline(span, uint(x-span), uint(y), colour) } } - } + + gui.renderScrollbar() } func (gui *GUI) redraw(shouldLock bool) { @@ -832,6 +891,16 @@ func (gui *GUI) monitorChangeCallback(monitor *glfw.Monitor, event glfw.MonitorE gui.SetDPIScale() } +func (gui *GUI) renderScrollbar() { + if gui.vScrollbar != nil { + position := gui.terminal.ActiveBuffer().GetVPosition() + maxPosition := int(gui.terminal.ActiveBuffer().GetMaxLines()) - int(gui.terminal.ActiveBuffer().ViewHeight()) + + gui.vScrollbar.setPosition(maxPosition, position) + gui.vScrollbar.render(gui) + } +} + // Synchronously executes the argument function in the main thread. // Does not return until f() executed! func (gui *GUI) executeInMainThread(f func() error) error { diff --git a/gui/mouse.go b/gui/mouse.go index 14c71c5..19703e9 100644 --- a/gui/mouse.go +++ b/gui/mouse.go @@ -36,7 +36,86 @@ func (gui *GUI) getArrowCursor() *glfw.Cursor { return gui.arrowCursor } -func (gui *GUI) mouseMoveCallback(w *glfw.Window, px float64, py float64) { +func (gui *GUI) scaleMouseCoordinates(px float64, py float64) (float64, float64) { + scale := float64(gui.scale()) + px = px / scale + py = py / scale + + return px, py +} + +func (gui *GUI) globalMouseMoveCallback(w *glfw.Window, px float64, py float64) { + px, py = gui.scaleMouseCoordinates(px, py) + + if gui.catchedMouseHandler != nil { + gui.catchedMouseHandler.mouseMoveCallback(gui, px, py) + } else { + if gui.isMouseInside(px, py) { + if gui.prevMouseEventHandler != gui { + if gui.prevMouseEventHandler != nil { + gui.prevMouseEventHandler.cursorEnterCallback(gui, false) + } + gui.cursorEnterCallback(gui, true) + } + gui.mouseMoveCallback(gui, px, py) + gui.prevMouseEventHandler = gui + } else if gui.vScrollbar != nil && gui.vScrollbar.isMouseInside(px, py) { + if gui.prevMouseEventHandler != gui.vScrollbar { + if gui.prevMouseEventHandler != nil { + gui.prevMouseEventHandler.cursorEnterCallback(gui, false) + } + gui.vScrollbar.cursorEnterCallback(gui, true) + } + gui.vScrollbar.mouseMoveCallback(gui, px, py) + gui.prevMouseEventHandler = gui.vScrollbar + } + } +} + +func (gui *GUI) globalMouseButtonCallback(w *glfw.Window, button glfw.MouseButton, action glfw.Action, mod glfw.ModifierKey) { + mouseX, mouseY := gui.scaleMouseCoordinates(w.GetCursorPos()) + + if gui.catchedMouseHandler != nil { + gui.catchedMouseHandler.mouseButtonCallback(gui, button, action, mod, mouseX, mouseY) + if action == glfw.Release && button == gui.mouseCatchedOnButton { + gui.catchMouse(nil, 0) + } + } else { + + if gui.isMouseInside(mouseX, mouseY) { + if action == glfw.Press { + gui.catchMouse(gui, button) + } + gui.mouseButtonCallback(gui, button, action, mod, mouseX, mouseY) + } else if gui.vScrollbar != nil && gui.vScrollbar.isMouseInside(mouseX, mouseY) { + if action == glfw.Press { + gui.catchMouse(gui.vScrollbar, button) + } + gui.vScrollbar.mouseButtonCallback(gui, button, action, mod, mouseX, mouseY) + } + } +} + +func (gui *GUI) globalCursorEnterCallback(w *glfw.Window, entered bool) { + if !entered { + if gui.prevMouseEventHandler != nil { + gui.prevMouseEventHandler.cursorEnterCallback(gui, false) + gui.prevMouseEventHandler = nil + } + } +} + +func (gui *GUI) catchMouse(newHandler mouseEventsHandler, button glfw.MouseButton) { + gui.catchedMouseHandler = newHandler + gui.mouseCatchedOnButton = button +} + +func (gui *GUI) isMouseInside(px float64, py float64) bool { + return px >= float64(gui.renderer.areaX) && px < float64(gui.renderer.areaX+gui.renderer.areaWidth) && + py >= float64(gui.renderer.areaY) && py < float64(gui.renderer.areaY+gui.renderer.areaHeight) +} + +func (gui *GUI) mouseMoveCallback(g *GUI, px float64, py float64) { x, y := gui.convertMouseCoordinates(px, py) @@ -59,16 +138,13 @@ func (gui *GUI) mouseMoveCallback(w *glfw.Window, px float64, py float64) { } if url := gui.terminal.ActiveBuffer().GetURLAtPosition(x, y); url != "" { - w.SetCursor(gui.getHandCursor()) + gui.window.SetCursor(gui.getHandCursor()) } else { - w.SetCursor(gui.getArrowCursor()) + gui.window.SetCursor(gui.getArrowCursor()) } } func (gui *GUI) convertMouseCoordinates(px float64, py float64) (uint16, uint16) { - scale := gui.scale() - px = px / float64(scale) - py = py / float64(scale) x := uint16(math.Floor((px - float64(gui.renderer.areaX)) / float64(gui.renderer.CellWidth()))) y := uint16(math.Floor((py - float64(gui.renderer.areaY)) / float64(gui.renderer.CellHeight()))) @@ -122,7 +198,7 @@ func btnCode(button glfw.MouseButton, release bool, mod glfw.ModifierKey) (b byt return b, true } -func (gui *GUI) mouseButtonCallback(w *glfw.Window, button glfw.MouseButton, action glfw.Action, mod glfw.ModifierKey) { +func (gui *GUI) mouseButtonCallback(g *GUI, button glfw.MouseButton, action glfw.Action, mod glfw.ModifierKey, mouseX float64, mouseY float64) { if gui.overlay != nil { if button == glfw.MouseButtonRight && action == glfw.Release { @@ -132,7 +208,7 @@ func (gui *GUI) mouseButtonCallback(w *glfw.Window, button glfw.MouseButton, act } // before we forward clicks on (below), we need to handle them locally for url clicking, text highlighting etc. - x, y := gui.convertMouseCoordinates(w.GetCursorPos()) + x, y := gui.convertMouseCoordinates(mouseX, mouseY) tx := int(x) + 1 // vt100 is 1 indexed ty := int(y) + 1 @@ -252,7 +328,10 @@ func (gui *GUI) mouseButtonCallback(w *glfw.Window, button glfw.MouseButton, act default: panic("Unsupported mouse mode") } +} +func (gui *GUI) cursorEnterCallback(g *GUI, entered bool) { + // empty, just to conform to the mouseEventsHandler interface } func (gui *GUI) handleSelectionButtonPress(x uint16, y uint16, mod glfw.ModifierKey) { diff --git a/gui/rectangleRenderer.go b/gui/rectangleRenderer.go new file mode 100644 index 0000000..484fe51 --- /dev/null +++ b/gui/rectangleRenderer.go @@ -0,0 +1,157 @@ +package gui + +import ( + "github.com/go-gl/gl/all-core/gl" + "github.com/liamg/aminal/config" +) + +const ( + rectangleRendererVertexShaderSource = ` + #version 330 core + layout (location = 0) in vec2 position; + uniform vec2 resolution; + + void main() { + // convert from window coordinates to GL coordinates + vec2 glCoordinates = ((position / resolution) * 2.0 - 1.0) * vec2(1, -1); + + gl_Position = vec4(glCoordinates, 0.0, 1.0); + }` + "\x00" + + rectangleRendererFragmentShaderSource = ` + #version 330 core + uniform vec4 inColor; + out vec4 outColor; + void main() { + outColor = inColor; + }` + "\x00" +) + +type rectangleRenderer struct { + program uint32 + vbo uint32 + vao uint32 + ibo uint32 + uniformLocationResolution int32 + uniformLocationInColor int32 +} + +func createRectangleRendererProgram() (uint32, error) { + vertexShader, err := compileShader(rectangleRendererVertexShaderSource, gl.VERTEX_SHADER) + if err != nil { + return 0, err + } + defer gl.DeleteShader(vertexShader) + + fragmentShader, err := compileShader(rectangleRendererFragmentShaderSource, gl.FRAGMENT_SHADER) + if err != nil { + return 0, err + } + defer gl.DeleteShader(fragmentShader) + + prog := gl.CreateProgram() + gl.AttachShader(prog, vertexShader) + gl.AttachShader(prog, fragmentShader) + gl.LinkProgram(prog) + + return prog, nil +} + +func newRectangleRenderer() (*rectangleRenderer, error) { + prog, err := createRectangleRendererProgram() + if err != nil { + return nil, err + } + + var vbo uint32 + var vao uint32 + var ibo uint32 + + gl.GenBuffers(1, &vbo) + gl.GenVertexArrays(1, &vao) + gl.GenBuffers(1, &ibo) + + indices := [...]uint32{ + 0, 1, 2, + 2, 3, 0, + } + + gl.BindVertexArray(vao) + + gl.BindBuffer(gl.ARRAY_BUFFER, vbo) + gl.BufferData(gl.ARRAY_BUFFER, 8*4, nil, gl.DYNAMIC_DRAW) // just reserve data for the buffer + + gl.BindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo) + gl.BufferData(gl.ELEMENT_ARRAY_BUFFER, len(indices)*4, gl.Ptr(&indices[0]), gl.DYNAMIC_DRAW) + + gl.VertexAttribPointer(0, 2, gl.FLOAT, false, 2*4, nil) + gl.EnableVertexAttribArray(0) + + gl.BindBuffer(gl.ARRAY_BUFFER, 0) + gl.BindVertexArray(0) + + return &rectangleRenderer{ + program: prog, + vbo: vbo, + vao: vao, + ibo: ibo, + uniformLocationResolution: gl.GetUniformLocation(prog, gl.Str("resolution\x00")), + uniformLocationInColor: gl.GetUniformLocation(prog, gl.Str("inColor\x00")), + }, nil +} + +func (rr *rectangleRenderer) Free() { + if rr.program != 0 { + gl.DeleteProgram(rr.program) + rr.program = 0 + } + + if rr.vbo != 0 { + gl.DeleteBuffers(1, &rr.vbo) + rr.vbo = 0 + } + + if rr.vao != 0 { + gl.DeleteBuffers(1, &rr.vao) + rr.vao = 0 + } + + if rr.ibo != 0 { + gl.DeleteBuffers(1, &rr.ibo) + rr.ibo = 0 + } +} + +func (rr *rectangleRenderer) render(left float32, top float32, width float32, height float32, colour config.Colour) { + var savedProgram int32 + gl.GetIntegerv(gl.CURRENT_PROGRAM, &savedProgram) + defer gl.UseProgram(uint32(savedProgram)) + + currentViewport := [4]int32{} + gl.GetIntegerv(gl.VIEWPORT, ¤tViewport[0]) + + gl.UseProgram(rr.program) + gl.Uniform2f(rr.uniformLocationResolution, float32(currentViewport[2]), float32(currentViewport[3])) + + gl.Uniform4f(rr.uniformLocationInColor, colour[0], colour[1], colour[2], 1.0) + + vertices := [...]float32{ + left, top, + left + width, top, + left + width, top + height, + left, top + height, + } + + /* + gl.NamedBufferSubData(rr.vbo, 0, len(vertices)*4, gl.Ptr(&vertices[0])) + /*/ + gl.BindBuffer(gl.ARRAY_BUFFER, rr.vbo) + gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(vertices)*4, gl.Ptr(&vertices[0])) + //*/ + + gl.BindVertexArray(rr.vao) + + gl.DrawElements(gl.TRIANGLES, 6, gl.UNSIGNED_INT, gl.PtrOffset(0)) + + gl.BindVertexArray(0) +} diff --git a/gui/renderer.go b/gui/renderer.go index b5aee10..9b9e6ab 100644 --- a/gui/renderer.go +++ b/gui/renderer.go @@ -1,13 +1,12 @@ package gui import ( - "image" - "math" - "github.com/go-gl/gl/all-core/gl" "github.com/liamg/aminal/buffer" "github.com/liamg/aminal/config" "github.com/liamg/aminal/glfont" + "image" + "math" ) type OpenGLRenderer struct { @@ -26,16 +25,8 @@ type OpenGLRenderer struct { textureMap map[*image.RGBA]uint32 fontMap *FontMap backgroundColour [3]float32 -} -type rectangle struct { - vao uint32 - vbo uint32 - cv uint32 - colourAttr uint32 - colour [3]float32 - points [18]float32 - prog uint32 + rectRenderer *rectangleRenderer } func (r *OpenGLRenderer) CellWidth() float32 { @@ -46,95 +37,12 @@ func (r *OpenGLRenderer) CellHeight() float32 { return r.cellHeight } -func (r *OpenGLRenderer) newRectangleEx(x float32, y float32, width float32, height float32, colourAttr uint32) *rectangle { - - rect := &rectangle{} - - halfAreaWidth := float32(r.areaWidth / 2) - halfAreaHeight := float32(r.areaHeight / 2) - - x = (x - halfAreaWidth) / halfAreaWidth - y = -(y - (halfAreaHeight)) / halfAreaHeight - w := width / halfAreaWidth - h := height / halfAreaHeight - - rect.points = [18]float32{ - x, y, 0, - x, y + h, 0, - x + w, y + h, 0, - - x + w, y, 0, - x, y, 0, - x + w, y + h, 0, +func NewOpenGLRenderer(config *config.Config, fontMap *FontMap, areaX int, areaY int, areaWidth int, areaHeight int, colourAttr uint32, program uint32) (*OpenGLRenderer, error) { + rectRenderer, err := newRectangleRenderer() + if err != nil { + return nil, err } - rect.colourAttr = colourAttr - rect.prog = r.program - - // SHAPE - gl.GenBuffers(1, &rect.vbo) - gl.BindBuffer(gl.ARRAY_BUFFER, rect.vbo) - gl.BufferData(gl.ARRAY_BUFFER, 4*len(rect.points), gl.Ptr(&rect.points[0]), gl.STATIC_DRAW) - - gl.GenVertexArrays(1, &rect.vao) - gl.BindVertexArray(rect.vao) - gl.EnableVertexAttribArray(0) - - gl.BindBuffer(gl.ARRAY_BUFFER, rect.vbo) - gl.VertexAttribPointer(0, 3, gl.FLOAT, false, 0, nil) - - // colour - gl.GenBuffers(1, &rect.cv) - - rect.setColour([3]float32{0, 1, 0}) - - return rect -} - -func (r *OpenGLRenderer) newRectangle(x float32, y float32, colourAttr uint32) *rectangle { - return r.newRectangleEx(x, y, r.cellWidth, r.cellHeight, colourAttr) -} - -func (rect *rectangle) Draw() { - gl.UseProgram(rect.prog) - gl.BindVertexArray(rect.vao) - gl.DrawArrays(gl.TRIANGLES, 0, 6) -} - -func (rect *rectangle) setColour(colour [3]float32) { - if rect.colour == colour { - return - } - - c := []float32{ - colour[0], colour[1], colour[2], - colour[0], colour[1], colour[2], - colour[0], colour[1], colour[2], - colour[0], colour[1], colour[2], - colour[0], colour[1], colour[2], - colour[0], colour[1], colour[2], - } - - gl.UseProgram(rect.prog) - gl.BindBuffer(gl.ARRAY_BUFFER, rect.cv) - gl.BufferData(gl.ARRAY_BUFFER, len(c)*4, gl.Ptr(c), gl.STATIC_DRAW) - gl.EnableVertexAttribArray(rect.colourAttr) - gl.VertexAttribPointer(rect.colourAttr, 3, gl.FLOAT, false, 0, gl.PtrOffset(0)) - - rect.colour = colour -} - -func (rect *rectangle) Free() { - gl.DeleteVertexArrays(1, &rect.vao) - gl.DeleteBuffers(1, &rect.vbo) - gl.DeleteBuffers(1, &rect.cv) - - rect.vao = 0 - rect.vbo = 0 - rect.cv = 0 -} - -func NewOpenGLRenderer(config *config.Config, fontMap *FontMap, areaX int, areaY int, areaWidth int, areaHeight int, colourAttr uint32, program uint32) *OpenGLRenderer { r := &OpenGLRenderer{ areaWidth: areaWidth, areaHeight: areaHeight, @@ -146,9 +54,10 @@ func NewOpenGLRenderer(config *config.Config, fontMap *FontMap, areaX int, areaY program: program, textureMap: map[*image.RGBA]uint32{}, fontMap: fontMap, + rectRenderer: rectRenderer, } r.SetArea(areaX, areaY, areaWidth, areaHeight) - return r + return r, nil } // This method ensures that all OpenGL resources are deleted correctly @@ -162,6 +71,11 @@ func (r *OpenGLRenderer) Free() { gl.DeleteProgram(r.program) r.program = 0 + + if r.rectRenderer != nil { + r.rectRenderer.Free() + r.rectRenderer = nil + } } func (r *OpenGLRenderer) GetTermSize() (uint, uint) { @@ -181,26 +95,16 @@ func (r *OpenGLRenderer) SetArea(areaX int, areaY int, areaWidth int, areaHeight r.termRows = uint(math.Floor(float64(float32(r.areaHeight) / r.cellHeight))) } -func (r *OpenGLRenderer) GetRectangleSize(col uint, row uint) (float32, float32) { - x := float32(float32(col) * r.cellWidth) - y := float32(float32(row) * r.cellHeight) +func (r *OpenGLRenderer) ConvertCoordinates(col uint, row uint) (float32, float32) { + left := float32(float32(col) * r.cellWidth) + top := float32(float32(row) * r.cellHeight) - return x, y -} - -func (r *OpenGLRenderer) getRectangle(col uint, row uint) *rectangle { - x := float32(float32(col) * r.cellWidth) - y := float32(float32(row)*r.cellHeight) + r.cellHeight - - return r.newRectangle(x, y, r.colourAttr) + return left, top } func (r *OpenGLRenderer) DrawCursor(col uint, row uint, colour config.Colour) { - rect := r.getRectangle(col, row) - rect.setColour(colour) - rect.Draw() - - rect.Free() + left, top := r.ConvertCoordinates(col, row) + r.rectRenderer.render(left, top, r.cellWidth, r.cellHeight, colour) } func (r *OpenGLRenderer) DrawCellBg(cell buffer.Cell, col uint, row uint, colour *config.Colour, force bool) { @@ -214,13 +118,9 @@ func (r *OpenGLRenderer) DrawCellBg(cell buffer.Cell, col uint, row uint, colour } if bg != r.backgroundColour || force { - rect := r.getRectangle(col, row) - rect.setColour(bg) - rect.Draw() - - rect.Free() + left, top := r.ConvertCoordinates(col, row) + r.rectRenderer.render(left, top, r.cellWidth, r.cellHeight, bg) } - } // DrawUnderline draws a line under 'span' characters starting at (col, row) @@ -233,12 +133,8 @@ func (r *OpenGLRenderer) DrawUnderline(span int, col uint, row uint, colour [3]f if thickness < 1 { thickness = 1 } - rect := r.newRectangleEx(x, y, r.cellWidth*float32(span), thickness, r.colourAttr) - rect.setColour(colour) - rect.Draw() - - rect.Free() + r.rectRenderer.render(x, y, r.cellWidth*float32(span), thickness, colour) } func (r *OpenGLRenderer) DrawCellText(text string, col uint, row uint, alpha float32, colour [3]float32, bold bool) { diff --git a/gui/scrollbar.go b/gui/scrollbar.go new file mode 100644 index 0000000..a8b5c54 --- /dev/null +++ b/gui/scrollbar.go @@ -0,0 +1,519 @@ +package gui + +import ( + "github.com/go-gl/gl/all-core/gl" + "github.com/go-gl/glfw/v3.2/glfw" +) + +const ( + scrollbarVertexShaderSource = ` + #version 330 core + layout (location = 0) in vec2 position; + uniform vec2 resolution; + + void main() { + // convert from window coordinates to GL coordinates + vec2 glCoordinates = ((position / resolution) * 2.0 - 1.0) * vec2(1, -1); + + gl_Position = vec4(glCoordinates, 0.0, 1.0); + }` + "\x00" + + scrollbarFragmentShaderSource = ` + #version 330 core + uniform vec4 inColor; + out vec4 outColor; + void main() { + outColor = inColor; + }` + "\x00" + + NumberOfVertexValues = 100 +) + +var ( + scrollbarColor_Bg = [3]float32{float32(241) / float32(255), float32(241) / float32(255), float32(241) / float32(255)} + scrollbarColor_ThumbNormal = [3]float32{float32(193) / float32(255), float32(193) / float32(255), float32(193) / float32(255)} + scrollbarColor_ThumbHover = [3]float32{float32(168) / float32(255), float32(168) / float32(255), float32(168) / float32(255)} + scrollbarColor_ThumbClicked = [3]float32{float32(120) / float32(255), float32(120) / float32(255), float32(120) / float32(255)} + + scrollbarColor_ButtonNormalBg = [3]float32{float32(241) / float32(255), float32(241) / float32(255), float32(241) / float32(255)} + scrollbarColor_ButtonNormalFg = [3]float32{float32(80) / float32(255), float32(80) / float32(255), float32(80) / float32(255)} + + scrollbarColor_ButtonHoverBg = [3]float32{float32(210) / float32(255), float32(210) / float32(255), float32(210) / float32(255)} + scrollbarColor_ButtonHoverFg = [3]float32{float32(80) / float32(255), float32(80) / float32(255), float32(80) / float32(255)} + + scrollbarColor_ButtonDisabledBg = [3]float32{float32(241) / float32(255), float32(241) / float32(255), float32(241) / float32(255)} + scrollbarColor_ButtonDisabledFg = [3]float32{float32(163) / float32(255), float32(163) / float32(255), float32(163) / float32(255)} + + scrollbarColor_ButtonClickedBg = [3]float32{float32(120) / float32(255), float32(120) / float32(255), float32(120) / float32(255)} + scrollbarColor_ButtonClickedFg = [3]float32{float32(255) / float32(255), float32(255) / float32(255), float32(255) / float32(255)} +) + +type scrollbarPart int + +const ( + None scrollbarPart = iota + UpperArrow + UpperSpace // the space between upper arrow and thumb + Thumb + BottomSpace // the space between thumb and bottom arrow + BottomArrow +) + +type ScreenRectangle struct { + left, top float32 // upper left corner in pixels relative to the window (in pixels) + right, bottom float32 +} + +func (sr *ScreenRectangle) width() float32 { + return sr.right - sr.left +} + +func (sr *ScreenRectangle) height() float32 { + return sr.bottom - sr.top +} + +func (sr *ScreenRectangle) isInside(x float32, y float32) bool { + return x >= sr.left && x < sr.right && + y >= sr.top && y < sr.bottom +} + +type scrollbar struct { + program uint32 + vbo uint32 + vao uint32 + uniformLocationResolution int32 + uniformLocationInColor int32 + + position ScreenRectangle // relative to the window's top left corner, in pixels + positionUpperArrow ScreenRectangle // relative to the control's top left corner + positionBottomArrow ScreenRectangle + positionThumb ScreenRectangle + + scrollPosition int + maxScrollPosition int + + thumbIsDragging bool + startedDraggingAtPosition int // scrollPosition when the dragging was started + startedDraggingAtThumbTop float32 // sb.positionThumb.top when the dragging was started + offsetInThumbY float32 // y offset inside the thumb of the dragging point + scrollPositionDelta int + + upperArrowIsDown bool + bottomArrowIsDown bool + + upperArrowFg []float32 + upperArrowBg []float32 + bottomArrowFg []float32 + bottomArrowBg []float32 + thumbColor []float32 +} + +// Returns the vertical scrollbar width in pixels +func getDefaultScrollbarWidth() int { + return 13 +} + +func createScrollbarProgram() (uint32, error) { + vertexShader, err := compileShader(scrollbarVertexShaderSource, gl.VERTEX_SHADER) + if err != nil { + return 0, err + } + defer gl.DeleteShader(vertexShader) + + fragmentShader, err := compileShader(scrollbarFragmentShaderSource, gl.FRAGMENT_SHADER) + if err != nil { + return 0, err + } + defer gl.DeleteShader(fragmentShader) + + prog := gl.CreateProgram() + gl.AttachShader(prog, vertexShader) + gl.AttachShader(prog, fragmentShader) + gl.LinkProgram(prog) + + return prog, nil +} + +func newScrollbar() (*scrollbar, error) { + prog, err := createScrollbarProgram() + if err != nil { + return nil, err + } + + var vbo uint32 + var vao uint32 + + gl.GenBuffers(1, &vbo) + gl.GenVertexArrays(1, &vao) + + gl.BindVertexArray(vao) + + gl.BindBuffer(gl.ARRAY_BUFFER, vbo) + gl.BufferData(gl.ARRAY_BUFFER, NumberOfVertexValues*4, nil, gl.DYNAMIC_DRAW) // only reserve the space + + gl.VertexAttribPointer(0, 2, gl.FLOAT, false, 2*4, nil) + gl.EnableVertexAttribArray(0) + + gl.BindBuffer(gl.ARRAY_BUFFER, 0) + gl.BindVertexArray(0) + + result := &scrollbar{ + program: prog, + vbo: vbo, + vao: vao, + uniformLocationResolution: gl.GetUniformLocation(prog, gl.Str("resolution\x00")), + uniformLocationInColor: gl.GetUniformLocation(prog, gl.Str("inColor\x00")), + + position: ScreenRectangle{ + right: 0, + bottom: 0, + left: 0, + top: 0, + }, + + scrollPosition: 0, + maxScrollPosition: 0, + + thumbIsDragging: false, + upperArrowIsDown: false, + bottomArrowIsDown: false, + } + + result.recalcElementPositions() + result.resetElementColors(-1, -1) // (-1, -1) ensures that no part is hovered by the mouse + + return result, nil +} + +func (sb *scrollbar) Free() { + if sb.program != 0 { + gl.DeleteProgram(sb.program) + sb.program = 0 + } + + if sb.vbo != 0 { + gl.DeleteBuffers(1, &sb.vbo) + sb.vbo = 0 + } + + if sb.vao != 0 { + gl.DeleteBuffers(1, &sb.vao) + sb.vao = 0 + } +} + +// Recalc positions of the scrollbar elements according to current +func (sb *scrollbar) recalcElementPositions() { + arrowHeight := sb.position.width() + + sb.positionUpperArrow = ScreenRectangle{ + left: 0, + top: 0, + right: sb.position.width(), + bottom: arrowHeight, + } + + sb.positionBottomArrow = ScreenRectangle{ + left: sb.positionUpperArrow.left, + top: sb.position.height() - arrowHeight, + right: sb.positionUpperArrow.right, + bottom: sb.position.height(), + } + thumbHeight := sb.position.width() + thumbTop := arrowHeight + if sb.maxScrollPosition != 0 { + thumbTop += (float32(sb.scrollPosition) * (sb.position.height() - thumbHeight - arrowHeight*2)) / float32(sb.maxScrollPosition) + } + + sb.positionThumb = ScreenRectangle{ + left: 2, + top: thumbTop, + right: sb.position.width() - 2, + bottom: thumbTop + thumbHeight, + } +} + +func (sb *scrollbar) resize(gui *GUI) { + sb.position.left = float32(gui.width) - float32(getDefaultScrollbarWidth())*gui.dpiScale + sb.position.top = float32(0.0) + sb.position.right = float32(gui.width) + sb.position.bottom = float32(gui.height - 1) + + sb.recalcElementPositions() + gui.terminal.NotifyDirty() +} + +func (sb *scrollbar) render(gui *GUI) { + var savedProgram int32 + gl.GetIntegerv(gl.CURRENT_PROGRAM, &savedProgram) + defer gl.UseProgram(uint32(savedProgram)) + + gl.UseProgram(sb.program) + gl.Uniform2f(sb.uniformLocationResolution, float32(gui.width), float32(gui.height)) + gl.BindVertexArray(sb.vao) + gl.BindBuffer(gl.ARRAY_BUFFER, sb.vbo) + defer func() { + gl.BindBuffer(gl.ARRAY_BUFFER, 0) + gl.BindVertexArray(0) + }() + + // Draw background + gl.Uniform4f(sb.uniformLocationInColor, scrollbarColor_Bg[0], scrollbarColor_Bg[1], scrollbarColor_Bg[2], 1.0) + borderVertices := [...]float32{ + sb.position.left, sb.position.top, + sb.position.right, sb.position.top, + sb.position.right, sb.position.bottom, + + sb.position.right, sb.position.bottom, + sb.position.left, sb.position.bottom, + sb.position.left, sb.position.top, + } + gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(borderVertices)*4, gl.Ptr(&borderVertices[0])) + gl.DrawArrays(gl.TRIANGLES, 0, int32(len(borderVertices)/2)) + + // Draw upper arrow + // Upper arrow background + gl.Uniform4f(sb.uniformLocationInColor, sb.upperArrowBg[0], sb.upperArrowBg[1], sb.upperArrowBg[2], 1.0) + upperArrowBgVertices := [...]float32{ + sb.position.left + sb.positionUpperArrow.left, sb.position.top + sb.positionUpperArrow.top, + sb.position.left + sb.positionUpperArrow.right, sb.position.top + sb.positionUpperArrow.top, + sb.position.left + sb.positionUpperArrow.right, sb.position.top + sb.positionUpperArrow.bottom, + + sb.position.left + sb.positionUpperArrow.right, sb.position.top + sb.positionUpperArrow.bottom, + sb.position.left + sb.positionUpperArrow.left, sb.position.top + sb.positionUpperArrow.bottom, + sb.position.left + sb.positionUpperArrow.left, sb.position.top + sb.positionUpperArrow.top, + } + gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(upperArrowBgVertices)*4, gl.Ptr(&upperArrowBgVertices[0])) + gl.DrawArrays(gl.TRIANGLES, 0, int32(len(upperArrowBgVertices)/2)) + + // Upper arrow foreground + gl.Uniform4f(sb.uniformLocationInColor, sb.upperArrowFg[0], sb.upperArrowFg[1], sb.upperArrowFg[2], 1.0) + upperArrowFgVertices := [...]float32{ + sb.position.left + sb.positionUpperArrow.left + sb.positionUpperArrow.width()/2.0, sb.position.top + sb.positionUpperArrow.top + sb.positionUpperArrow.height()/3.0, + sb.position.left + sb.positionUpperArrow.left + sb.positionUpperArrow.width()*2.0/3.0, sb.position.top + sb.positionUpperArrow.top + sb.positionUpperArrow.height()/2.0, + sb.position.left + sb.positionUpperArrow.left + sb.positionUpperArrow.width()/3.0, sb.position.top + sb.positionUpperArrow.top + sb.positionUpperArrow.height()/2.0, + } + gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(upperArrowFgVertices)*4, gl.Ptr(&upperArrowFgVertices[0])) + gl.DrawArrays(gl.TRIANGLES, 0, int32(len(upperArrowFgVertices)/2)) + + // Draw bottom arrow + // Bottom arrow background + gl.Uniform4f(sb.uniformLocationInColor, sb.bottomArrowBg[0], sb.bottomArrowBg[1], sb.bottomArrowBg[2], 1.0) + bottomArrowBgVertices := [...]float32{ + sb.position.left + sb.positionBottomArrow.left, sb.position.top + sb.positionBottomArrow.top, + sb.position.left + sb.positionBottomArrow.right, sb.position.top + sb.positionBottomArrow.top, + sb.position.left + sb.positionBottomArrow.right, sb.position.top + sb.positionBottomArrow.bottom, + + sb.position.left + sb.positionBottomArrow.right, sb.position.top + sb.positionBottomArrow.bottom, + sb.position.left + sb.positionBottomArrow.left, sb.position.top + sb.positionBottomArrow.bottom, + sb.position.left + sb.positionBottomArrow.left, sb.position.top + sb.positionBottomArrow.top, + } + gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(bottomArrowBgVertices)*4, gl.Ptr(&bottomArrowBgVertices[0])) + gl.DrawArrays(gl.TRIANGLES, 0, int32(len(bottomArrowBgVertices)/2)) + + // Bottom arrow foreground + gl.Uniform4f(sb.uniformLocationInColor, sb.bottomArrowFg[0], sb.bottomArrowFg[1], sb.bottomArrowFg[2], 1.0) + bottomArrowFgVertices := [...]float32{ + sb.position.left + sb.positionBottomArrow.left + sb.positionBottomArrow.width()/3.0, sb.position.top + sb.positionBottomArrow.top + sb.positionBottomArrow.height()/2.0, + sb.position.left + sb.positionBottomArrow.left + sb.positionBottomArrow.width()*2.0/3.0, sb.position.top + sb.positionBottomArrow.top + sb.positionBottomArrow.height()/2.0, + sb.position.left + sb.positionBottomArrow.left + sb.positionBottomArrow.width()/2.0, sb.position.top + sb.positionBottomArrow.top + sb.positionBottomArrow.height()*2.0/3.0, + } + gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(bottomArrowFgVertices)*4, gl.Ptr(&bottomArrowFgVertices[0])) + gl.DrawArrays(gl.TRIANGLES, 0, int32(len(bottomArrowFgVertices)/2)) + + // Draw thumb + gl.Uniform4f(sb.uniformLocationInColor, sb.thumbColor[0], sb.thumbColor[1], sb.thumbColor[2], 1.0) + thumbVertices := [...]float32{ + sb.position.left + sb.positionThumb.left, sb.position.top + sb.positionThumb.top, + sb.position.left + sb.positionThumb.right, sb.position.top + sb.positionThumb.top, + sb.position.left + sb.positionThumb.right, sb.position.top + sb.positionThumb.bottom, + + sb.position.left + sb.positionThumb.right, sb.position.top + sb.positionThumb.bottom, + sb.position.left + sb.positionThumb.left, sb.position.top + sb.positionThumb.bottom, + sb.position.left + sb.positionThumb.left, sb.position.top + sb.positionThumb.top, + } + gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(thumbVertices)*4, gl.Ptr(&thumbVertices[0])) + gl.DrawArrays(gl.TRIANGLES, 0, int32(len(thumbVertices)/2)) + + gui.terminal.NotifyDirty() +} + +func (sb *scrollbar) setPosition(max int, position int) { + if max <= 0 { + max = position + } + + if position > max { + position = max + } + + sb.maxScrollPosition = max + sb.scrollPosition = position + + sb.recalcElementPositions() +} + +func (sb *scrollbar) mouseHitTest(px float64, py float64) scrollbarPart { + // convert to local coordinates + mouseX := float32(px - float64(sb.position.left)) + mouseY := float32(py - float64(sb.position.top)) + + result := None + + if sb.positionUpperArrow.isInside(mouseX, mouseY) { + result = UpperArrow + } else if sb.positionBottomArrow.isInside(mouseX, mouseY) { + result = BottomArrow + } else if sb.positionThumb.isInside(mouseX, mouseY) { + result = Thumb + } else { + // construct UpperSpace + pos := ScreenRectangle{ + left: sb.positionThumb.left, + top: sb.positionUpperArrow.bottom, + right: sb.positionThumb.right, + bottom: sb.positionThumb.top, + } + + if pos.isInside(mouseX, mouseY) { + result = UpperSpace + } + + // now update it to be BottomSpace + pos.top = sb.positionThumb.bottom + pos.bottom = sb.positionBottomArrow.top + if pos.isInside(mouseX, mouseY) { + result = BottomSpace + } + } + + return result +} + +func (sb *scrollbar) isMouseInside(px float64, py float64) bool { + return sb.position.isInside(float32(px), float32(py)) +} + +func (sb *scrollbar) mouseButtonCallback(g *GUI, button glfw.MouseButton, action glfw.Action, mod glfw.ModifierKey, mouseX float64, mouseY float64) { + if button == glfw.MouseButtonLeft { + if action == glfw.Press { + switch sb.mouseHitTest(mouseX, mouseY) { + case UpperArrow: + sb.upperArrowIsDown = true + g.terminal.ScreenScrollUp(1) + + case UpperSpace: + g.terminal.ScrollPageUp() + + case Thumb: + sb.thumbIsDragging = true + sb.startedDraggingAtPosition = sb.scrollPosition + sb.startedDraggingAtThumbTop = sb.positionThumb.top + sb.offsetInThumbY = float32(mouseY) - sb.position.top - sb.positionThumb.top + sb.scrollPositionDelta = 0 + + case BottomSpace: + g.terminal.ScrollPageDown() + + case BottomArrow: + sb.bottomArrowIsDown = true + g.terminal.ScreenScrollDown(1) + } + } else if action == glfw.Release { + if sb.thumbIsDragging { + sb.thumbIsDragging = false + } + + if sb.upperArrowIsDown { + sb.upperArrowIsDown = false + } + + if sb.bottomArrowIsDown { + sb.bottomArrowIsDown = false + } + } + + g.terminal.NotifyDirty() + } + + sb.resetElementColors(mouseX, mouseY) +} + +func (sb *scrollbar) mouseMoveCallback(g *GUI, px float64, py float64) { + sb.resetElementColors(px, py) + + if sb.thumbIsDragging { + py -= float64(sb.position.top) + + minThumbTop := sb.positionUpperArrow.bottom + maxThumbTop := sb.positionBottomArrow.top - sb.positionThumb.height() + + newThumbTop := float32(py) - sb.offsetInThumbY + + newPositionDelta := int((float32(sb.maxScrollPosition) * (newThumbTop - minThumbTop - sb.startedDraggingAtThumbTop)) / (maxThumbTop - minThumbTop)) + + if newPositionDelta > sb.scrollPositionDelta { + scrollLines := newPositionDelta - sb.scrollPositionDelta + g.logger.Debugf("old position: %d, new position delta: %d, scroll down %d lines", sb.scrollPosition, newPositionDelta, scrollLines) + g.terminal.ScreenScrollDown(uint16(scrollLines)) + sb.scrollPositionDelta = newPositionDelta + } else if newPositionDelta < sb.scrollPositionDelta { + scrollLines := sb.scrollPositionDelta - newPositionDelta + g.logger.Debugf("old position: %d, new position delta: %d, scroll up %d lines", sb.scrollPosition, newPositionDelta, scrollLines) + g.terminal.ScreenScrollUp(uint16(scrollLines)) + sb.scrollPositionDelta = newPositionDelta + } + + sb.recalcElementPositions() + g.logger.Debugf("new thumbTop: %f, fact thumbTop: %f, position: %d", newThumbTop, sb.positionThumb.top, sb.scrollPosition) + } + + g.terminal.NotifyDirty() +} + +func (sb *scrollbar) resetElementColors(mouseX float64, mouseY float64) { + part := sb.mouseHitTest(mouseX, mouseY) + + if sb.scrollPosition == 0 { + sb.upperArrowBg = scrollbarColor_ButtonDisabledBg[:] + sb.upperArrowFg = scrollbarColor_ButtonDisabledFg[:] + } else if sb.upperArrowIsDown { + sb.upperArrowFg = scrollbarColor_ButtonClickedFg[:] + sb.upperArrowBg = scrollbarColor_ButtonClickedBg[:] + } else if part == UpperArrow { + sb.upperArrowFg = scrollbarColor_ButtonHoverFg[:] + sb.upperArrowBg = scrollbarColor_ButtonHoverBg[:] + } else { + sb.upperArrowFg = scrollbarColor_ButtonNormalFg[:] + sb.upperArrowBg = scrollbarColor_ButtonNormalBg[:] + } + + if sb.scrollPosition == sb.maxScrollPosition { + sb.bottomArrowBg = scrollbarColor_ButtonDisabledBg[:] + sb.bottomArrowFg = scrollbarColor_ButtonDisabledFg[:] + } else if sb.bottomArrowIsDown { + sb.bottomArrowFg = scrollbarColor_ButtonClickedFg[:] + sb.bottomArrowBg = scrollbarColor_ButtonClickedBg[:] + } else if part == BottomArrow { + sb.bottomArrowFg = scrollbarColor_ButtonHoverFg[:] + sb.bottomArrowBg = scrollbarColor_ButtonHoverBg[:] + } else { + sb.bottomArrowFg = scrollbarColor_ButtonNormalFg[:] + sb.bottomArrowBg = scrollbarColor_ButtonNormalBg[:] + } + + if sb.thumbIsDragging { + sb.thumbColor = scrollbarColor_ThumbClicked[:] + } else if part == Thumb { + sb.thumbColor = scrollbarColor_ThumbHover[:] + } else { + sb.thumbColor = scrollbarColor_ThumbNormal[:] + } +} + +func (sb *scrollbar) cursorEnterCallback(g *GUI, entered bool) { + if !entered { + sb.resetElementColors(-1, -1) // (-1, -1) ensures that no part is hovered by the mouse + g.terminal.NotifyDirty() + } +} diff --git a/main.go b/main.go index 1b4114c..b0b4eab 100644 --- a/main.go +++ b/main.go @@ -72,6 +72,7 @@ func initialize(unitTestfunc callback, configOverride *config.Config) { if err != nil { logger.Fatalf("Cannot start: %s", err) } + defer g.Free() terminal.WindowManipulation = g diff --git a/main_test.go b/main_test.go index ab91005..1e833c7 100644 --- a/main_test.go +++ b/main_test.go @@ -198,6 +198,9 @@ func TestExit(t *testing.T) { func testConfig() *config.Config { c := config.DefaultConfig() + // Force the scrollbar not showing when running unit tests + c.ShowVerticalScrollbar = false + // Use a vanilla shell on POSIX to help ensure consistency. if runtime.GOOS != "windows" { c.Shell = "/bin/sh" From 070f29ad13f509587d761078e5d30747687cac02 Mon Sep 17 00:00:00 2001 From: nikitar020 <42252263+nikitar020@users.noreply.github.com> Date: Wed, 20 Mar 2019 04:45:43 +0200 Subject: [PATCH 8/9] Solve intermittent build errors (#262) * Solve intermittent build errors * Eliminate `panic()` in `gui.Screenshot()` function --- gui/gui.go | 47 +++++++++++++---------- main.go | 15 +++++++- main_test.go | 106 ++++++++++++++++++++++++++++++++++----------------- 3 files changed, 112 insertions(+), 56 deletions(-) diff --git a/gui/gui.go b/gui/gui.go index 7c6b9af..b35a8b8 100644 --- a/gui/gui.go +++ b/gui/gui.go @@ -373,7 +373,6 @@ func (gui *GUI) Close() { } func (gui *GUI) Render() error { - gui.logger.Debugf("Creating window...") var err error gui.window, err = gui.createWindow() @@ -470,16 +469,6 @@ func (gui *GUI) Render() error { gl.Disable(gl.DEPTH_TEST) gl.TexParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - - go func() { - for { - <-ticker.C - gui.logger.Sync() - } - }() - gui.terminal.SetProgram(program) latestVersion := "" @@ -496,7 +485,9 @@ func (gui *GUI) Render() error { showMessage := true stop := make(chan struct{}) - go gui.waker(stop) + var waitForWaker sync.WaitGroup + waitForWaker.Add(1) + go gui.waker(stop, &waitForWaker) for !gui.window.ShouldClose() { gui.redraw(true) @@ -560,9 +551,13 @@ Buffer Size: %d lines } } - close(stop) // Tell waker to end. + gui.logger.Debug("Stopping render...") + + close(stop) // Tell waker to end... + waitForWaker.Wait() // ...and wait it to end + + gui.logger.Debug("Render stopped") - gui.logger.Debugf("Stopping render...") return nil } @@ -571,10 +566,13 @@ Buffer Size: %d lines // 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{}) { +func (gui *GUI) waker(stop <-chan struct{}, wg *sync.WaitGroup) { + defer wg.Done() + dirty := gui.terminal.Dirty() var nextWake <-chan time.Time var last time.Time +forLoop: for { select { case <-dirty: @@ -598,7 +596,7 @@ func (gui *GUI) waker(stop <-chan struct{}) { glfw.PostEmptyEvent() nextWake = nil case <-stop: - return + break forLoop } } } @@ -869,18 +867,27 @@ func (gui *GUI) SwapBuffers() { gui.window.SwapBuffers() } -func (gui *GUI) Screenshot(path string) { +func (gui *GUI) Screenshot(path string) error { x, y := gui.window.GetPos() w, h := gui.window.GetSize() img, err := screenshot.CaptureRect(image.Rectangle{Min: image.Point{X: x, Y: y}, Max: image.Point{X: x + w, Y: y + h}}) if err != nil { - panic(err) + return err + } + file, err := os.Create(path) + if err != nil { + return err } - file, _ := os.Create(path) defer file.Close() - png.Encode(file, img) + err = png.Encode(file, img) + if err != nil { + os.Remove(path) + return err + } + + return nil } func (gui *GUI) windowPosChangeCallback(w *glfw.Window, xpos int, ypos int) { diff --git a/main.go b/main.go index b0b4eab..63d6a69 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "github.com/liamg/aminal/platform" "github.com/liamg/aminal/terminal" "github.com/riywo/loginshell" + "time" ) type callback func(terminal *terminal.Terminal, g *gui.GUI) @@ -32,7 +33,17 @@ func initialize(unitTestfunc callback, configOverride *config.Config) { fmt.Printf("Failed to create logger: %s\n", err) os.Exit(1) } - defer logger.Sync() + ticker := time.NewTicker(time.Second) + go func() { + for { + <-ticker.C + logger.Sync() + } + }() + defer func() { + ticker.Stop() + logger.Sync() + }() if conf.CPUProfile != "" { logger.Infof("Starting CPU profiling...") @@ -61,7 +72,7 @@ func initialize(unitTestfunc callback, configOverride *config.Config) { guestProcess, err := pty.CreateGuestProcess(shellStr) if err != nil { pty.Close() - logger.Fatalf("Failed to start your shell: %s", err) + logger.Fatalf("Failed to start your shell %q: %s", shellStr, err) } defer guestProcess.Close() diff --git a/main_test.go b/main_test.go index 1e833c7..e82de2d 100644 --- a/main_test.go +++ b/main_test.go @@ -11,11 +11,10 @@ import ( "testing" "time" + "github.com/carlogit/phash" "github.com/liamg/aminal/config" "github.com/liamg/aminal/gui" "github.com/liamg/aminal/terminal" - - "github.com/carlogit/phash" ) var termRef *terminal.Terminal @@ -47,10 +46,14 @@ func hash(path string) string { return imageHash } +func imagesAreEqual(expected string, actual string) int { + expectedHash := hash(expected) + actualHash := hash(actual) + return phash.GetDistance(expectedHash, actualHash) +} + func compareImages(expected string, actual string) { - template := hash(expected) - screen := hash(actual) - distance := phash.GetDistance(template, screen) + distance := imagesAreEqual(expected, actual) if distance != 0 { os.Exit(terminate(fmt.Sprintf("Screenshot \"%s\" doesn't match expected image \"%s\". Distance of hashes difference: %d\n", actual, expected, distance))) @@ -58,19 +61,55 @@ func compareImages(expected string, actual string) { } func send(terminal *terminal.Terminal, cmd string) { - terminal.Write([]byte(cmd)) + err := terminal.Write([]byte(cmd)) + if err != nil { + panic(err) + } } func enter(terminal *terminal.Terminal) { - terminal.Write([]byte("\n")) + err := terminal.Write([]byte("\n")) + if err != nil { + panic(err) + } } -func validateScreen(img string) { - guiRef.Screenshot(img) +func validateScreen(img string, waitForChange bool) { + fmt.Printf("taking screenshot: %s and comparing...", img) + + err := guiRef.Screenshot(img) + if err != nil { + panic(err) + } + compareImages(strings.Join([]string{"vttest/", img}, ""), img) + fmt.Printf("compare OK\n") + enter(termRef) - sleep() + + if waitForChange { + fmt.Print("Waiting for screen change...") + attempts := 10 + for { + sleep() + actualScren := "temp.png" + err = guiRef.Screenshot(actualScren) + if err != nil { + panic(err) + } + distance := imagesAreEqual(actualScren, img) + if distance != 0 { + break + } + fmt.Printf(" %d", attempts) + attempts-- + if attempts <= 0 { + break + } + } + fmt.Print("done\n") + } } func TestMain(m *testing.M) { @@ -115,12 +154,12 @@ func TestCursorMovement(t *testing.T) { os.Exit(terminate(fmt.Sprintf("ActiveBuffer doesn't match vttest template vttest/test-cursor-movement-1"))) } - validateScreen("test-cursor-movement-1.png") - validateScreen("test-cursor-movement-2.png") - validateScreen("test-cursor-movement-3.png") - validateScreen("test-cursor-movement-4.png") - validateScreen("test-cursor-movement-5.png") - validateScreen("test-cursor-movement-6.png") + validateScreen("test-cursor-movement-1.png", true) + validateScreen("test-cursor-movement-2.png", true) + validateScreen("test-cursor-movement-3.png", true) + validateScreen("test-cursor-movement-4.png", true) + validateScreen("test-cursor-movement-5.png", true) + validateScreen("test-cursor-movement-6.png", false) g.Close() } @@ -142,21 +181,21 @@ func TestScreenFeatures(t *testing.T) { send(term, "2\n") sleep() - validateScreen("test-screen-features-1.png") - validateScreen("test-screen-features-2.png") - validateScreen("test-screen-features-3.png") - validateScreen("test-screen-features-4.png") - validateScreen("test-screen-features-5.png") - validateScreen("test-screen-features-6.png") - validateScreen("test-screen-features-7.png") - validateScreen("test-screen-features-8.png") - validateScreen("test-screen-features-9.png") - validateScreen("test-screen-features-10.png") - validateScreen("test-screen-features-11.png") - validateScreen("test-screen-features-12.png") - validateScreen("test-screen-features-13.png") - validateScreen("test-screen-features-14.png") - validateScreen("test-screen-features-15.png") + validateScreen("test-screen-features-1.png", true) + validateScreen("test-screen-features-2.png", true) + validateScreen("test-screen-features-3.png", true) + validateScreen("test-screen-features-4.png", true) + validateScreen("test-screen-features-5.png", true) + validateScreen("test-screen-features-6.png", true) + validateScreen("test-screen-features-7.png", true) + validateScreen("test-screen-features-8.png", true) + validateScreen("test-screen-features-9.png", true) + validateScreen("test-screen-features-10.png", true) + validateScreen("test-screen-features-11.png", true) + validateScreen("test-screen-features-12.png", true) + validateScreen("test-screen-features-13.png", true) + validateScreen("test-screen-features-14.png", true) + validateScreen("test-screen-features-15.png", false) g.Close() } @@ -178,10 +217,9 @@ func TestSixel(t *testing.T) { send(term, "clear\n") sleep() send(term, "cat example.sixel\n") - sleep(4) + sleep(10) // Displaying SIXEL graphics *sometimes* takes long time. Excessive synchronization??? - guiRef.Screenshot("test-sixel.png") - validateScreen("test-sixel.png") + validateScreen("test-sixel.png", false) g.Close() } From 68953464ba04a71c9a5bd622354b6cb2166e0dbf Mon Sep 17 00:00:00 2001 From: rrrooommmaaa Date: Wed, 20 Mar 2019 12:51:42 +0300 Subject: [PATCH 9/9] #249 no newlines when copying (#261) --- buffer/buffer.go | 3 ++- buffer/line.go | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/buffer/buffer.go b/buffer/buffer.go index 7ec9f93..a362a69 100644 --- a/buffer/buffer.go +++ b/buffer/buffer.go @@ -215,7 +215,7 @@ func (buffer *Buffer) GetSelectedText(selectionRegionMode SelectionRegionMode) s maxX := int(buffer.terminalState.viewWidth) - 1 if row == start.Line { minX = start.Col - } else if !line.wrapped { + } else if !line.wrapped && !line.nobreak { builder.WriteString("\n") } if row == end.Line { @@ -704,6 +704,7 @@ func (buffer *Buffer) Write(runes ...rune) { buffer.NewLineEx(true) newLine := buffer.getCurrentLine() + newLine.setNoBreak(true) if len(newLine.cells) == 0 { newLine.Append(buffer.terminalState.DefaultCell(true)) } diff --git a/buffer/line.go b/buffer/line.go index 270b26f..3919d44 100644 --- a/buffer/line.go +++ b/buffer/line.go @@ -6,12 +6,14 @@ import ( type Line struct { wrapped bool // whether line was wrapped onto from the previous one + nobreak bool // true if no line break at the beginning of the line cells []Cell } func newLine() Line { return Line{ wrapped: false, + nobreak: false, cells: []Cell{}, } } @@ -45,6 +47,10 @@ func (line *Line) setWrapped(wrapped bool) { line.wrapped = wrapped } +func (line *Line) setNoBreak(nobreak bool) { + line.nobreak = nobreak +} + func (line *Line) String() string { runes := []rune{} for _, cell := range line.cells {