Implement three selection modes: char, word, and line. Make selection work in the same way as in Putty. (#191)

This commit is contained in:
nikitar020 2019-02-04 13:24:49 +00:00 committed by Liam Galvin
parent f7b162e83e
commit 22a5e8063a
4 changed files with 223 additions and 122 deletions

View File

@ -9,6 +9,13 @@ import (
"strings" "strings"
) )
type SelectionMode int
const (
SelectionChar SelectionMode = iota // char-by-char selection
SelectionWord SelectionMode = iota // by word selection
SelectionLine SelectionMode = iota // whole line selection
)
type Buffer struct { type Buffer struct {
lines []Line lines []Line
displayChangeHandlers []chan bool displayChangeHandlers []chan bool
@ -18,7 +25,8 @@ type Buffer struct {
dirty bool dirty bool
selectionStart *Position selectionStart *Position
selectionEnd *Position selectionEnd *Position
selectionComplete bool // whether the selected text can update or whether it is final selectionMode SelectionMode
isSelectionComplete bool
terminalState *TerminalState terminalState *TerminalState
savedCharsets []*map[rune]rune savedCharsets []*map[rune]rune
savedCurrentCharset int savedCurrentCharset int
@ -29,10 +37,25 @@ type Position struct {
Col int Col int
} }
func comparePositions(pos1 *Position, pos2 *Position) int {
if pos1.Line < pos2.Line || (pos1.Line == pos2.Line && pos1.Col < pos2.Col) {
return 1
}
if pos1.Line > pos2.Line || (pos1.Line == pos2.Line && pos1.Col > pos2.Col) {
return -1
}
return 0
}
// NewBuffer creates a new terminal buffer // NewBuffer creates a new terminal buffer
func NewBuffer(terminalState *TerminalState) *Buffer { func NewBuffer(terminalState *TerminalState) *Buffer {
b := &Buffer{ b := &Buffer{
lines: []Line{}, lines: []Line{},
selectionStart: nil,
selectionEnd: nil,
selectionMode: SelectionChar,
isSelectionComplete: true,
terminalState: terminalState, terminalState: terminalState,
} }
return b return b
@ -84,50 +107,13 @@ func (buffer *Buffer) GetURLAtPosition(col uint16, viewRow uint16) string {
} }
func (buffer *Buffer) IsSelectionComplete() bool { func (buffer *Buffer) IsSelectionComplete() bool {
return buffer.selectionComplete return buffer.isSelectionComplete
} }
func (buffer *Buffer) SelectLineAtPosition(col uint16, viewRow uint16) { func (buffer *Buffer) findEndOfWord(col int, row int) int {
row := buffer.convertViewLineToRawLine(viewRow) - uint64(buffer.terminalState.scrollLinesFromBottom)
buffer.selectionStart = &Position {
Col: 0,
Line: int(row),
}
buffer.selectionEnd = &Position {
Col: int(buffer.ViewWidth() - 1),
Line: int(row),
}
buffer.selectionComplete = true
buffer.emitDisplayChange()
}
func (buffer *Buffer) SelectWordAtPosition(col uint16, viewRow uint16) {
row := buffer.convertViewLineToRawLine(viewRow) - uint64(buffer.terminalState.scrollLinesFromBottom)
cell := buffer.GetRawCell(col, row)
if cell == nil || cell.Rune() == 0x00 {
return
}
start := col
end := col end := col
for i := col; i < int(buffer.terminalState.viewWidth); i++ {
for i := col; i >= uint16(0); i-- { cell := buffer.GetRawCell(uint16(i), uint64(row))
cell := buffer.GetRawCell(i, row)
if cell == nil {
break
}
if isRuneWordSelectionMarker(cell.Rune()) {
break
}
start = i
}
for i := col; i < buffer.terminalState.viewWidth; i++ {
cell := buffer.GetRawCell(i, row)
if cell == nil { if cell == nil {
break break
} }
@ -136,18 +122,22 @@ func (buffer *Buffer) SelectWordAtPosition(col uint16, viewRow uint16) {
} }
end = i end = i
} }
return end
buffer.selectionStart = &Position{
Col: int(start),
Line: int(row),
}
buffer.selectionEnd = &Position{
Col: int(end),
Line: int(row),
} }
buffer.selectionComplete = true func (buffer *Buffer) findBeginningOfWord(col int, row int) int {
buffer.emitDisplayChange() start := col
for i := col; i >= 0; i-- {
cell := buffer.GetRawCell(uint16(i), uint64(row))
if cell == nil {
break
}
if isRuneWordSelectionMarker(cell.Rune()) {
break
}
start = i
}
return start
} }
// bounds for word selection // bounds for word selection
@ -170,29 +160,15 @@ func isRuneURLSelectionMarker(r rune) bool {
} }
func (buffer *Buffer) GetSelectedText() string { func (buffer *Buffer) GetSelectedText() string {
if buffer.selectionStart == nil || buffer.selectionEnd == nil { start, end := buffer.getActualSelection()
if start == nil || end == nil {
return "" return ""
} }
var x1, x2, y1, y2 int
if buffer.selectionStart.Line > buffer.selectionEnd.Line || (buffer.selectionStart.Line == buffer.selectionEnd.Line && buffer.selectionStart.Col > buffer.selectionEnd.Col) {
y2 = buffer.selectionStart.Line
y1 = buffer.selectionEnd.Line
x2 = buffer.selectionStart.Col
x1 = buffer.selectionEnd.Col
} else {
y1 = buffer.selectionStart.Line
y2 = buffer.selectionEnd.Line
x1 = buffer.selectionStart.Col
x2 = buffer.selectionEnd.Col
}
var builder strings.Builder var builder strings.Builder
builder.Grow( int(buffer.terminalState.viewWidth) * (y2 - y1 + 1)) // reserve space to minimize allocations builder.Grow( int(buffer.terminalState.viewWidth) * (end.Line - start.Line + 1)) // reserve space to minimize allocations
for row := y1; row <= y2; row++ {
for row := start.Line; row <= end.Line; row++ {
if row >= len(buffer.lines) { if row >= len(buffer.lines) {
break break
} }
@ -201,13 +177,13 @@ func (buffer *Buffer) GetSelectedText() string {
minX := 0 minX := 0
maxX := int(buffer.terminalState.viewWidth) - 1 maxX := int(buffer.terminalState.viewWidth) - 1
if row == y1 { if row == start.Line {
minX = x1 minX = start.Col
} else if !line.wrapped { } else if !line.wrapped {
builder.WriteString("\n") builder.WriteString("\n")
} }
if row == y2 { if row == end.Line {
maxX = x2 maxX = end.Col
} }
for col := minX; col <= maxX; col++ { for col := minX; col <= maxX; col++ {
@ -225,26 +201,35 @@ func (buffer *Buffer) GetSelectedText() string {
return builder.String() return builder.String()
} }
func (buffer *Buffer) StartSelection(col uint16, viewRow uint16) { func (buffer *Buffer) StartSelection(col uint16, viewRow uint16, mode SelectionMode) {
row := buffer.convertViewLineToRawLine(viewRow) - uint64(buffer.terminalState.scrollLinesFromBottom) row := buffer.convertViewLineToRawLine(viewRow) - uint64(buffer.terminalState.scrollLinesFromBottom)
buffer.selectionComplete = false buffer.selectionMode = mode
buffer.selectionStart = &Position { buffer.selectionStart = &Position {
Col: int(col), Col: int(col),
Line: int(row), Line: int(row),
} }
if mode == SelectionChar {
buffer.selectionEnd = nil buffer.selectionEnd = nil
} else {
buffer.selectionEnd = &Position {
Col: int(col),
Line: int(row),
}
} }
func (buffer *Buffer) EndSelection(col uint16, viewRow uint16, complete bool) { buffer.isSelectionComplete = false
if buffer.selectionComplete { buffer.emitDisplayChange()
}
func (buffer *Buffer) ExtendSelection(col uint16, viewRow uint16, complete bool) {
if buffer.isSelectionComplete {
return return
} }
buffer.selectionComplete = complete
defer buffer.emitDisplayChange() defer buffer.emitDisplayChange()
if buffer.selectionStart == nil { if buffer.selectionStart == nil {
@ -254,47 +239,82 @@ func (buffer *Buffer) EndSelection(col uint16, viewRow uint16, complete bool) {
row := buffer.convertViewLineToRawLine(viewRow) - uint64(buffer.terminalState.scrollLinesFromBottom) row := buffer.convertViewLineToRawLine(viewRow) - uint64(buffer.terminalState.scrollLinesFromBottom)
if int(col) == buffer.selectionStart.Col && int(row) == int(buffer.selectionStart.Line) && complete {
return
}
buffer.selectionEnd = &Position { buffer.selectionEnd = &Position {
Col: int(col), Col: int(col),
Line: int(row), Line: int(row),
} }
if complete {
buffer.isSelectionComplete = true
}
} }
func (buffer *Buffer) ClearSelection() { func (buffer *Buffer) ClearSelection() {
buffer.selectionStart = nil buffer.selectionStart = nil
buffer.selectionEnd = nil buffer.selectionEnd = nil
buffer.selectionComplete = true buffer.isSelectionComplete = true
buffer.emitDisplayChange() buffer.emitDisplayChange()
} }
func (buffer *Buffer) InSelection(col uint16, row uint16) bool { func (buffer *Buffer) getActualSelection() (*Position, *Position) {
if buffer.selectionStart == nil || buffer.selectionEnd == nil { if buffer.selectionStart == nil || buffer.selectionEnd == nil {
return nil, nil
}
start := &Position {}
end := &Position {}
if comparePositions(buffer.selectionStart, buffer.selectionEnd) >= 0 {
start.Col = buffer.selectionStart.Col
start.Line = buffer.selectionStart.Line
end.Col = buffer.selectionEnd.Col
end.Line = buffer.selectionEnd.Line
} else {
start.Col = buffer.selectionEnd.Col
start.Line = buffer.selectionEnd.Line
end.Col = buffer.selectionStart.Col
end.Line = buffer.selectionStart.Line
}
switch buffer.selectionMode {
case SelectionChar:
// no action
case SelectionWord:
start.Col = buffer.findBeginningOfWord(start.Col, start.Line)
end.Col = buffer.findEndOfWord(end.Col, end.Line)
case SelectionLine:
start.Col = 0
end.Col = int(buffer.ViewWidth() - 1)
}
if start.Line >= len(buffer.lines) {
start.Col = 0
} else if start.Col >= len(buffer.lines[start.Line].cells) {
start.Col = len(buffer.lines[start.Line].cells)
}
if end.Line >= len(buffer.lines) || end.Col >= len(buffer.lines[end.Line].cells) {
end.Col = int(buffer.ViewWidth() - 1)
}
return start, end
}
func (buffer *Buffer) InSelection(col uint16, row uint16) bool {
start, end := buffer.getActualSelection()
if start == nil || end == nil {
return false return false
} }
var x1, x2, y1, y2 int
// first, let's put the selection points in the correct order, earliest first
if buffer.selectionStart.Line > buffer.selectionEnd.Line || (buffer.selectionStart.Line == buffer.selectionEnd.Line && buffer.selectionStart.Col > buffer.selectionEnd.Col) {
y2 = buffer.selectionStart.Line
y1 = buffer.selectionEnd.Line
x2 = buffer.selectionStart.Col
x1 = buffer.selectionEnd.Col
} else {
y1 = buffer.selectionStart.Line
y2 = buffer.selectionEnd.Line
x1 = buffer.selectionStart.Col
x2 = buffer.selectionEnd.Col
}
rawY := int(buffer.convertViewLineToRawLine(row) - uint64(buffer.terminalState.scrollLinesFromBottom)) rawY := int(buffer.convertViewLineToRawLine(row) - uint64(buffer.terminalState.scrollLinesFromBottom))
return (rawY > y1 || (rawY == y1 && int(col) >= x1)) && (rawY < y2 || (rawY == y2 && int(col) <= x2))
return (rawY > start.Line || (rawY == start.Line && int(col) >= start.Col)) &&
(rawY < end.Line || (rawY == end.Line && int(col) <= end.Col))
} }
func (buffer *Buffer) IsDirty() bool { func (buffer *Buffer) IsDirty() bool {

View File

@ -639,3 +639,75 @@ func TestBufferMaxLines(t *testing.T) {
assert.Equal(t, "funny", b.lines[0].String()) assert.Equal(t, "funny", b.lines[0].String())
assert.Equal(t, "world", b.lines[1].String()) 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.StartSelection(2, 0, SelectionChar)
b.ExtendSelection(4, 1, true)
assert.Equal(t, "e quick brown\nfox j", b.GetSelectedText())
}
func TestSelectingWordsDown(t *testing.T) {
b := makeBufferForTestingSelection()
b.StartSelection(6, 1, SelectionWord)
b.ExtendSelection(5, 2, true)
assert.Equal(t, "jumps over\nthe lazy", b.GetSelectedText())
}
func TestSelectingWordsUp(t *testing.T) {
b := makeBufferForTestingSelection()
b.StartSelection(5, 2, SelectionWord)
b.ExtendSelection(6, 1, true)
assert.Equal(t, "jumps over\nthe lazy", b.GetSelectedText())
}
func TestSelectingLinesDown(t *testing.T) {
b := makeBufferForTestingSelection()
b.StartSelection(6, 1, SelectionLine)
b.ExtendSelection(4, 2, true)
assert.Equal(t, "fox jumps over\nthe lazy dog", b.GetSelectedText())
}
func TestSelectingLineUp(t *testing.T) {
b := makeBufferForTestingSelection()
b.StartSelection(8, 2, SelectionLine)
b.ExtendSelection(3, 1, true)
assert.Equal(t, "fox jumps over\nthe lazy dog", b.GetSelectedText())
}
func TestSelectingAfterText(t *testing.T) {
b := makeBufferForTestingSelection()
b.StartSelection(6, 3, SelectionChar)
b.ExtendSelection(6, 3, true)
start, end := b.getActualSelection()
assert.Equal(t, start.Col, 0)
assert.Equal(t, start.Line, 3)
assert.Equal(t, end.Col, 79)
assert.Equal(t, end.Line, 3)
}

View File

@ -54,6 +54,7 @@ type GUI struct {
prevLeftClickY uint16 prevLeftClickY uint16
leftClickTime time.Time leftClickTime time.Time
leftClickCount int // number of clicks in a serie - single click, double click, or triple click leftClickCount int // number of clicks in a serie - single click, double click, or triple click
mouseMovedAfterSelectionStarted bool
internalResize bool internalResize bool
} }

View File

@ -7,6 +7,7 @@ import (
"github.com/go-gl/glfw/v3.2/glfw" "github.com/go-gl/glfw/v3.2/glfw"
"github.com/liamg/aminal/terminal" "github.com/liamg/aminal/terminal"
"time" "time"
"github.com/liamg/aminal/buffer"
) )
func (gui *GUI) glfwScrollCallback(w *glfw.Window, xoff float64, yoff float64) { func (gui *GUI) glfwScrollCallback(w *glfw.Window, xoff float64, yoff float64) {
@ -39,7 +40,7 @@ func (gui *GUI) mouseMoveCallback(w *glfw.Window, px float64, py float64) {
x, y := gui.convertMouseCoordinates(px, py) x, y := gui.convertMouseCoordinates(px, py)
if gui.mouseDown { if gui.mouseDown {
gui.terminal.ActiveBuffer().EndSelection(x, y, false) gui.terminal.ActiveBuffer().ExtendSelection(x, y, false)
} else { } else {
hint := gui.terminal.ActiveBuffer().GetHintAtPosition(x, y) hint := gui.terminal.ActiveBuffer().GetHintAtPosition(x, y)
@ -48,7 +49,6 @@ func (gui *GUI) mouseMoveCallback(w *glfw.Window, px float64, py float64) {
} else { } else {
gui.setOverlay(nil) gui.setOverlay(nil)
} }
} }
if url := gui.terminal.ActiveBuffer().GetURLAtPosition(x, y); url != "" { if url := gui.terminal.ActiveBuffer().GetURLAtPosition(x, y); url != "" {
@ -109,20 +109,28 @@ func (gui *GUI) mouseButtonCallback(w *glfw.Window, button glfw.MouseButton, act
gui.mouseDown = true gui.mouseDown = true
clickCount := gui.updateLeftClickCount(x, y) clickCount := gui.updateLeftClickCount(x, y)
if clickCount == 1 || !activeBuffer.IsSelectionComplete() {
activeBuffer.StartSelection(x, y)
} else {
switch clickCount { switch clickCount {
case 1:
activeBuffer.StartSelection(x, y, buffer.SelectionChar)
case 2: case 2:
activeBuffer.SelectWordAtPosition(x, y) activeBuffer.StartSelection(x, y, buffer.SelectionWord)
case 3: case 3:
activeBuffer.SelectLineAtPosition(x, y) activeBuffer.StartSelection(x, y, buffer.SelectionLine)
}
} }
gui.mouseMovedAfterSelectionStarted = false
} else if action == glfw.Release { } else if action == glfw.Release {
gui.mouseDown = false gui.mouseDown = false
activeBuffer.EndSelection(x, y, true)
if x != gui.prevLeftClickX || y != gui.prevLeftClickY {
gui.mouseMovedAfterSelectionStarted = true
}
if gui.leftClickCount != 1 || gui.mouseMovedAfterSelectionStarted {
activeBuffer.ExtendSelection(x, y, true)
}
// Do copy to clipboard *or* open URL, but not both.
handled := false handled := false
if gui.config.CopyAndPasteWithMouse { if gui.config.CopyAndPasteWithMouse {
selectedText := activeBuffer.GetSelectedText() selectedText := activeBuffer.GetSelectedText()