From 6df9ad1cf3fd6b9e47658da140c7b3c9b4b21bf4 Mon Sep 17 00:00:00 2001 From: Liam Galvin Date: Wed, 24 Oct 2018 12:15:43 +0100 Subject: [PATCH] text selection and copy/paste --- README.md | 11 +-- buffer/buffer.go | 188 +++++++++++++++++++++++++++++++++++++++++++ config/colours.go | 1 + config/defaults.go | 1 + gui/gui.go | 13 ++- gui/input.go | 3 + gui/mouse.go | 35 ++++++-- gui/renderer.go | 35 +++++--- terminal/terminal.go | 6 -- 9 files changed, 259 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index dfa0bf9..8fc6883 100644 --- a/README.md +++ b/README.md @@ -58,18 +58,19 @@ Ensure you have your latest graphics card drivers installed before use. | Resizing/content reordering | ✔ | | ANSI escape codes | ✔ | | UTF-8 input | ✔ | -| UTF-8 output | ✔ | Works as long as the font in use supports the relevant characters. -| Copy/paste | | Paste working, no mouse interaction for copy -| Customisable colour schemes | ✔ | Complete, but the config file has no entry for this yet -| Config file | ✔ | Minimal options atm +| UTF-8 output | ✔ | +| Copy/paste | ✔ | +| Customisable colour schemes | ✔ | +| Config file | ✔ | | Scrolling | ✔ | -| Mouse interaction | | +| Mouse interaction | ✔ | | Sweet render effects | | ## Keyboard Shortcuts | Operation | Key(s) | | ------------------ | ---------------- | +| Copy | ctrl + shift + c | | Paste | ctrl + shift + v | | Toggle slomo | ctrl + shift + ; | | Interrupt (SIGINT) | ctrl + c | diff --git a/buffer/buffer.go b/buffer/buffer.go index 372dc7c..c994eab 100644 --- a/buffer/buffer.go +++ b/buffer/buffer.go @@ -2,6 +2,7 @@ package buffer import ( "fmt" + "time" ) type Buffer struct { @@ -20,6 +21,16 @@ type Buffer struct { replaceMode bool // overwrite character at cursor or insert new autoWrap bool dirty bool + selectionStart *Position + selectionEnd *Position + selectionComplete bool // whether the selected text can update or whether it is final + selectionExpanded bool // whether the selection to word expansion has already run on this point + selectionClickTime time.Time +} + +type Position struct { + Line int + Col int } // NewBuffer creates a new terminal buffer @@ -36,6 +47,183 @@ func NewBuffer(viewCols uint16, viewLines uint16, attr CellAttributes) *Buffer { return b } +func (buffer *Buffer) SelectWordAtPosition(col uint16, row uint16) { + + cell := buffer.GetCell(col, row) + if cell == nil || cell.Rune() == 0x00 { + return + } + + start := col + end := col + + for i := col; i >= 0; i-- { + cell := buffer.GetCell(i, row) + if cell == nil { + break + } + if isRuneSelectionMarker(cell.Rune()) { + break + } + start = i + } + + for i := col; i < buffer.viewWidth; i++ { + cell := buffer.GetCell(i, row) + if cell == nil { + break + } + if isRuneSelectionMarker(cell.Rune()) { + break + } + end = i + } + + buffer.selectionStart = &Position{ + Col: int(start), + Line: int(buffer.convertViewLineToRawLine(row)), + } + buffer.selectionEnd = &Position{ + Col: int(end), + Line: int(buffer.convertViewLineToRawLine(row)), + } + buffer.emitDisplayChange() + +} + +// bounds for word selection +func isRuneSelectionMarker(r rune) bool { + switch r { + case ',', ' ', ':', ';', 0, '\'', '"', '[', ']', '(', ')', '{', '}': + return true + } + + return false +} + +func (buffer *Buffer) GetSelectedText() string { + if buffer.selectionStart == nil || buffer.selectionEnd == nil { + return "" + } + + text := "" + + for row := buffer.selectionStart.Line; row <= buffer.selectionEnd.Line; row++ { + + if row >= len(buffer.lines) { + break + } + + line := buffer.lines[row] + + minX := 0 + maxX := int(buffer.viewWidth) - 1 + if row == buffer.selectionStart.Line { + minX = buffer.selectionStart.Col + } else if !line.wrapped { + text += "\n" + } + if row == buffer.selectionEnd.Line { + maxX = buffer.selectionEnd.Col + } + + for col := minX; col <= maxX; col++ { + if col >= len(line.cells) { + break + } + cell := line.cells[col] + text += string(cell.Rune()) + } + + } + + return text +} + +func (buffer *Buffer) StartSelection(col uint16, row uint16) { + if buffer.selectionComplete { + buffer.selectionEnd = nil + + if buffer.selectionStart != nil && time.Since(buffer.selectionClickTime) < time.Millisecond*500 { + if buffer.selectionExpanded { + //select whole line! + buffer.selectionStart = &Position{ + Col: 0, + Line: int(buffer.convertViewLineToRawLine(row)), + } + buffer.selectionEnd = &Position{ + Col: int(buffer.ViewWidth() - 1), + Line: int(buffer.convertViewLineToRawLine(row)), + } + buffer.emitDisplayChange() + } else { + buffer.SelectWordAtPosition(col, row) + buffer.selectionExpanded = true + } + return + } + + buffer.selectionExpanded = false + } + + buffer.selectionComplete = false + buffer.selectionStart = &Position{ + Col: int(col), + Line: int(buffer.convertViewLineToRawLine(row)), + } + buffer.selectionClickTime = time.Now() +} + +func (buffer *Buffer) EndSelection(col uint16, row uint16, complete bool) { + + if buffer.selectionComplete { + return + } + + buffer.selectionComplete = complete + + defer buffer.emitDisplayChange() + + if buffer.selectionStart == nil { + buffer.selectionEnd = nil + return + } + + if int(col) == buffer.selectionStart.Col && int(buffer.convertViewLineToRawLine(row)) == int(buffer.selectionStart.Line) && complete { + return + } + + buffer.selectionEnd = &Position{ + Col: int(col), + Line: int(buffer.convertViewLineToRawLine(row)), + } +} + +func (buffer *Buffer) InSelection(col uint16, row uint16) bool { + + if buffer.selectionStart == nil || buffer.selectionEnd == 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)) + return (rawY > y1 || (rawY == y1 && int(col) >= x1)) && (rawY < y2 || (rawY == y2 && int(col) <= x2)) +} + func (buffer *Buffer) IsDirty() bool { if !buffer.dirty { return false diff --git a/config/colours.go b/config/colours.go index a7325a6..5e9d1a3 100644 --- a/config/colours.go +++ b/config/colours.go @@ -73,4 +73,5 @@ type ColourScheme struct { LightMagenta Colour `toml:"light_magenta"` LightCyan Colour `toml:"light_cyan"` White Colour `toml:"white"` + Selection Colour `toml:"selection"` } diff --git a/config/defaults.go b/config/defaults.go index 6e14b02..a4c32e1 100644 --- a/config/defaults.go +++ b/config/defaults.go @@ -22,5 +22,6 @@ var DefaultConfig = Config{ LightMagenta: strToColourNoErr("#ff99a1"), LightCyan: strToColourNoErr("#9ed9d8"), White: strToColourNoErr("#f6f6c9"), + Selection: strToColourNoErr("#333366"), }, } diff --git a/gui/gui.go b/gui/gui.go index a91bdcd..dbe1511 100644 --- a/gui/gui.go +++ b/gui/gui.go @@ -29,6 +29,7 @@ type GUI struct { fontScale float32 renderer *OpenGLRenderer colourAttr uint32 + mouseDown bool } func New(config *config.Config, terminal *terminal.Terminal, logger *zap.SugaredLogger) *GUI { @@ -134,6 +135,7 @@ func (gui *GUI) Render() error { gui.window.SetCharCallback(gui.char) gui.window.SetScrollCallback(gui.glfwScrollCallback) gui.window.SetMouseButtonCallback(gui.mouseButtonCallback) + gui.window.SetCursorPosCallback(gui.mouseMoveCallback) gui.window.SetRefreshCallback(func(w *glfw.Window) { gui.terminal.SetDirty() }) @@ -224,9 +226,16 @@ func (gui *GUI) Render() error { cy = cy + uint(gui.terminal.GetScrollOffset()) cursor = cx == uint(x) && cy == uint(y) } - gui.renderer.DrawCellBg(cell, uint(x), uint(y), cursor) + + var colour *config.Colour + + if gui.terminal.ActiveBuffer().InSelection(uint16(x), uint16(y)) { + colour = &gui.config.ColourScheme.Selection + } + + gui.renderer.DrawCellBg(cell, uint(x), uint(y), cursor, colour) if hasText { - gui.renderer.DrawCellText(cell, uint(x), uint(y)) + gui.renderer.DrawCellText(cell, uint(x), uint(y), nil) } } } diff --git a/gui/input.go b/gui/input.go index fb57eb9..347cae2 100644 --- a/gui/input.go +++ b/gui/input.go @@ -36,6 +36,9 @@ func (gui *GUI) key(w *glfw.Window, key glfw.Key, scancode int, action glfw.Acti case modsPressed(mods, glfw.ModControl, glfw.ModShift): modStr = "6" switch key { + case glfw.KeyC: + gui.window.SetClipboardString(gui.terminal.ActiveBuffer().GetSelectedText()) + return case glfw.KeyV: if s, err := gui.window.GetClipboardString(); err == nil { _ = gui.terminal.Paste([]byte(s)) diff --git a/gui/mouse.go b/gui/mouse.go index 25d67ed..11daa14 100644 --- a/gui/mouse.go +++ b/gui/mouse.go @@ -8,8 +8,32 @@ import ( "github.com/liamg/aminal/terminal" ) +func (gui *GUI) mouseMoveCallback(w *glfw.Window, xpos float64, ypos float64) { + + if gui.mouseDown { + px, py := w.GetCursorPos() + 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()))) + gui.terminal.ActiveBuffer().EndSelection(x, y, false) + } +} + func (gui *GUI) mouseButtonCallback(w *glfw.Window, button glfw.MouseButton, action glfw.Action, mod glfw.ModifierKey) { + // before we forward clicks on (below), we need to handle them locally for url clicking, text highlighting etc. + px, py := w.GetCursorPos() + 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()))) + tx := int(x) + 1 // vt100 is 1 indexed + ty := int(y) + 1 + + if action == glfw.Press { + gui.mouseDown = true + gui.terminal.ActiveBuffer().StartSelection(x, y) + } else if action == glfw.Release { + gui.mouseDown = false + gui.terminal.ActiveBuffer().EndSelection(x, y, true) + } // https://www.xfree86.org/4.8.0/ctlseqs.html /* @@ -40,10 +64,7 @@ func (gui *GUI) mouseButtonCallback(w *glfw.Window, button glfw.MouseButton, act if action == glfw.Press { b := rune(button) - px, py := w.GetCursorPos() - x := int(math.Floor(px/float64(gui.renderer.CellWidth()))) + 1 - y := int(math.Floor(py/float64(gui.renderer.CellHeight()))) + 1 - packet := fmt.Sprintf("\x1b[M%c%c%c", (rune(b + 32)), (rune(x + 32)), (rune(y + 32))) + packet := fmt.Sprintf("\x1b[M%c%c%c", (rune(b + 32)), (rune(tx + 32)), (rune(ty + 32))) gui.terminal.Write([]byte(packet)) } @@ -96,10 +117,8 @@ func (gui *GUI) mouseButtonCallback(w *glfw.Window, button glfw.MouseButton, act if mod&glfw.ModControl > 0 { b |= 16 } - px, py := w.GetCursorPos() - x := int(math.Floor(px/float64(gui.renderer.CellWidth()))) + 1 - y := int(math.Floor(py/float64(gui.renderer.CellHeight()))) + 1 - packet := fmt.Sprintf("\x1b[M%c%c%c", (rune(b + 32)), (rune(x + 32)), (rune(y + 32))) + + packet := fmt.Sprintf("\x1b[M%c%c%c", (rune(b + 32)), (rune(tx + 32)), (rune(ty + 32))) gui.logger.Infof("Sending mouse packet: '%v'", packet) gui.terminal.Write([]byte(packet)) diff --git a/gui/renderer.go b/gui/renderer.go index f68bf6f..c37bd93 100644 --- a/gui/renderer.go +++ b/gui/renderer.go @@ -192,16 +192,21 @@ func (r *OpenGLRenderer) DrawCursor(col uint, row uint, colour config.Colour) { rect.Draw() } -func (r *OpenGLRenderer) DrawCellBg(cell buffer.Cell, col uint, row uint, cursor bool) { +func (r *OpenGLRenderer) DrawCellBg(cell buffer.Cell, col uint, row uint, cursor bool, colour *config.Colour) { var bg [3]float32 - if cursor { - bg = r.config.ColourScheme.Cursor - } else if cell.Attr().Reverse { - bg = cell.Fg() + if colour != nil { + bg = *colour } else { - bg = cell.Bg() + + if cursor { + bg = r.config.ColourScheme.Cursor + } else if cell.Attr().Reverse { + bg = cell.Fg() + } else { + bg = cell.Bg() + } } if bg != r.config.ColourScheme.Background { @@ -211,14 +216,18 @@ func (r *OpenGLRenderer) DrawCellBg(cell buffer.Cell, col uint, row uint, cursor } } -func (r *OpenGLRenderer) DrawCellText(cell buffer.Cell, col uint, row uint) { +func (r *OpenGLRenderer) DrawCellText(cell buffer.Cell, col uint, row uint, colour *config.Colour) { var fg [3]float32 - if cell.Attr().Reverse { - fg = cell.Bg() + if colour != nil { + fg = *colour } else { - fg = cell.Fg() + if cell.Attr().Reverse { + fg = cell.Bg() + } else { + fg = cell.Fg() + } } var alpha float32 = 1 @@ -227,12 +236,12 @@ func (r *OpenGLRenderer) DrawCellText(cell buffer.Cell, col uint, row uint) { } r.font.SetColor(fg[0], fg[1], fg[2], alpha) - x := float32(col) * r.cellWidth - y := (float32(row+1) * r.cellHeight) - (r.font.LinePadding()) + x := float32(r.areaX) + float32(col)*r.cellWidth + y := float32(r.areaY) + (float32(row+1) * r.cellHeight) - (r.font.LinePadding()) if cell.Attr().Bold { // bold means draw text again one pixel to right, so it's fatter if r.boldFont != nil { - y := (float32(row+1) * r.cellHeight) - (r.boldFont.LinePadding()) + y := float32(r.areaY) + (float32(row+1) * r.cellHeight) - (r.boldFont.LinePadding()) r.boldFont.SetColor(fg[0], fg[1], fg[2], alpha) r.boldFont.Print(x, y, string(cell.Rune())) return diff --git a/terminal/terminal.go b/terminal/terminal.go index b5c1fc6..f2b1646 100644 --- a/terminal/terminal.go +++ b/terminal/terminal.go @@ -62,11 +62,6 @@ type Winsize struct { y uint16 //ignored, but necessary for ioctl calls } -type Position struct { - Line int - Col int -} - func New(pty *os.File, logger *zap.SugaredLogger, config *config.Config) *Terminal { return &Terminal{ @@ -92,7 +87,6 @@ func New(pty *os.File, logger *zap.SugaredLogger, config *config.Config) *Termin } } - func (terminal *Terminal) SetBracketedPasteMode(enabled bool) { terminal.bracketedPasteMode = enabled }