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"
)
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 {
lines []Line
displayChangeHandlers []chan bool
@ -18,7 +25,8 @@ type Buffer struct {
dirty bool
selectionStart *Position
selectionEnd *Position
selectionComplete bool // whether the selected text can update or whether it is final
selectionMode SelectionMode
isSelectionComplete bool
terminalState *TerminalState
savedCharsets []*map[rune]rune
savedCurrentCharset int
@ -29,10 +37,25 @@ type Position struct {
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
func NewBuffer(terminalState *TerminalState) *Buffer {
b := &Buffer{
lines: []Line{},
selectionStart: nil,
selectionEnd: nil,
selectionMode: SelectionChar,
isSelectionComplete: true,
terminalState: terminalState,
}
return b
@ -84,50 +107,13 @@ func (buffer *Buffer) GetURLAtPosition(col uint16, viewRow uint16) string {
}
func (buffer *Buffer) IsSelectionComplete() bool {
return buffer.selectionComplete
return buffer.isSelectionComplete
}
func (buffer *Buffer) SelectLineAtPosition(col uint16, viewRow uint16) {
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
func (buffer *Buffer) findEndOfWord(col int, row int) int {
end := col
for i := col; i >= uint16(0); i-- {
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)
for i := col; i < int(buffer.terminalState.viewWidth); i++ {
cell := buffer.GetRawCell(uint16(i), uint64(row))
if cell == nil {
break
}
@ -136,18 +122,22 @@ func (buffer *Buffer) SelectWordAtPosition(col uint16, viewRow uint16) {
}
end = i
}
buffer.selectionStart = &Position{
Col: int(start),
Line: int(row),
}
buffer.selectionEnd = &Position{
Col: int(end),
Line: int(row),
return end
}
buffer.selectionComplete = true
buffer.emitDisplayChange()
func (buffer *Buffer) findBeginningOfWord(col int, row int) int {
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
@ -170,29 +160,15 @@ func isRuneURLSelectionMarker(r rune) bool {
}
func (buffer *Buffer) GetSelectedText() string {
if buffer.selectionStart == nil || buffer.selectionEnd == nil {
start, end := buffer.getActualSelection()
if start == nil || end == nil {
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
builder.Grow( int(buffer.terminalState.viewWidth) * (y2 - y1 + 1)) // reserve space to minimize allocations
for row := y1; row <= y2; row++ {
builder.Grow( int(buffer.terminalState.viewWidth) * (end.Line - start.Line + 1)) // reserve space to minimize allocations
for row := start.Line; row <= end.Line; row++ {
if row >= len(buffer.lines) {
break
}
@ -201,13 +177,13 @@ func (buffer *Buffer) GetSelectedText() string {
minX := 0
maxX := int(buffer.terminalState.viewWidth) - 1
if row == y1 {
minX = x1
if row == start.Line {
minX = start.Col
} else if !line.wrapped {
builder.WriteString("\n")
}
if row == y2 {
maxX = x2
if row == end.Line {
maxX = end.Col
}
for col := minX; col <= maxX; col++ {
@ -225,26 +201,35 @@ func (buffer *Buffer) GetSelectedText() 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)
buffer.selectionComplete = false
buffer.selectionMode = mode
buffer.selectionStart = &Position {
Col: int(col),
Line: int(row),
}
if mode == SelectionChar {
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
}
buffer.selectionComplete = complete
defer buffer.emitDisplayChange()
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)
if int(col) == buffer.selectionStart.Col && int(row) == int(buffer.selectionStart.Line) && complete {
return
}
buffer.selectionEnd = &Position {
Col: int(col),
Line: int(row),
}
if complete {
buffer.isSelectionComplete = true
}
}
func (buffer *Buffer) ClearSelection() {
buffer.selectionStart = nil
buffer.selectionEnd = nil
buffer.selectionComplete = true
buffer.isSelectionComplete = true
buffer.emitDisplayChange()
}
func (buffer *Buffer) InSelection(col uint16, row uint16) bool {
func (buffer *Buffer) getActualSelection() (*Position, *Position) {
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
}
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))
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 {

View File

@ -639,3 +639,75 @@ func TestBufferMaxLines(t *testing.T) {
assert.Equal(t, "funny", b.lines[0].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
leftClickTime time.Time
leftClickCount int // number of clicks in a serie - single click, double click, or triple click
mouseMovedAfterSelectionStarted bool
internalResize bool
}

View File

@ -7,6 +7,7 @@ import (
"github.com/go-gl/glfw/v3.2/glfw"
"github.com/liamg/aminal/terminal"
"time"
"github.com/liamg/aminal/buffer"
)
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)
if gui.mouseDown {
gui.terminal.ActiveBuffer().EndSelection(x, y, false)
gui.terminal.ActiveBuffer().ExtendSelection(x, y, false)
} else {
hint := gui.terminal.ActiveBuffer().GetHintAtPosition(x, y)
@ -48,7 +49,6 @@ func (gui *GUI) mouseMoveCallback(w *glfw.Window, px float64, py float64) {
} else {
gui.setOverlay(nil)
}
}
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
clickCount := gui.updateLeftClickCount(x, y)
if clickCount == 1 || !activeBuffer.IsSelectionComplete() {
activeBuffer.StartSelection(x, y)
} else {
switch clickCount {
case 1:
activeBuffer.StartSelection(x, y, buffer.SelectionChar)
case 2:
activeBuffer.SelectWordAtPosition(x, y)
activeBuffer.StartSelection(x, y, buffer.SelectionWord)
case 3:
activeBuffer.SelectLineAtPosition(x, y)
}
activeBuffer.StartSelection(x, y, buffer.SelectionLine)
}
gui.mouseMovedAfterSelectionStarted = false
} else if action == glfw.Release {
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
if gui.config.CopyAndPasteWithMouse {
selectedText := activeBuffer.GetSelectedText()