mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-06-09 22:05:16 +02:00
bfdea2bb4f
Bound to alt-delete on Mac, and ctrl-delete on Windows and Linux.
674 lines
16 KiB
Go
674 lines
16 KiB
Go
package gocui
|
|
|
|
import (
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/rivo/uniseg"
|
|
)
|
|
|
|
const (
|
|
WHITESPACES = " \t"
|
|
WORD_SEPARATORS = "*?_+-.[]~=/&;!#$%^(){}<>"
|
|
)
|
|
|
|
type TextAreaCell struct {
|
|
char string // string because it could be a multi-rune grapheme cluster
|
|
width int
|
|
x, y int // cell coordinates
|
|
contentIndex int // byte index into the original content
|
|
}
|
|
|
|
// returns the cursor x,y position after this cell
|
|
func (c *TextAreaCell) nextCursorXY() (int, int) {
|
|
if c.char == "\n" {
|
|
return 0, c.y + 1
|
|
}
|
|
return c.x + c.width, c.y
|
|
}
|
|
|
|
type TextArea struct {
|
|
content string
|
|
cells []TextAreaCell
|
|
cursor int // position in content, as an index into the byte array
|
|
overwrite bool
|
|
clipboard string
|
|
AutoWrap bool
|
|
AutoWrapWidth int
|
|
}
|
|
|
|
func stringToTextAreaCells(str string) []TextAreaCell {
|
|
result := make([]TextAreaCell, 0, len(str))
|
|
|
|
contentIndex := 0
|
|
state := -1
|
|
for len(str) > 0 {
|
|
var c string
|
|
var w int
|
|
c, str, w, state = uniseg.FirstGraphemeClusterInString(str, state)
|
|
// only set char, width, and contentIndex; x and y will be set later
|
|
result = append(result, TextAreaCell{char: c, width: w, contentIndex: contentIndex})
|
|
contentIndex += len(c)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Returns the indices in content where soft line breaks occur due to auto-wrapping to the given width.
|
|
func AutoWrapContent(content string, autoWrapWidth int) []int {
|
|
_, softLineBreakIndices := contentToCells(content, autoWrapWidth)
|
|
return softLineBreakIndices
|
|
}
|
|
|
|
func contentToCells(content string, autoWrapWidth int) ([]TextAreaCell, []int) {
|
|
estimatedNumberOfSoftLineBreaks := 0
|
|
if autoWrapWidth > 0 {
|
|
estimatedNumberOfSoftLineBreaks = len(content) / autoWrapWidth
|
|
}
|
|
softLineBreakIndices := make([]int, 0, estimatedNumberOfSoftLineBreaks)
|
|
result := make([]TextAreaCell, 0, len(content)+estimatedNumberOfSoftLineBreaks)
|
|
startOfLine := 0
|
|
currentLineWidth := 0
|
|
indexOfLastWhitespace := -1
|
|
var footNoteMatcher footNoteMatcher
|
|
var trailerMatcher trailerMatcher
|
|
|
|
cells := stringToTextAreaCells(content)
|
|
y := 0
|
|
|
|
appendCellsSinceLineStart := func(to int) {
|
|
x := 0
|
|
for i := startOfLine; i < to; i++ {
|
|
cells[i].x = x
|
|
cells[i].y = y
|
|
x += cells[i].width
|
|
}
|
|
|
|
result = append(result, cells[startOfLine:to]...)
|
|
}
|
|
|
|
for currentPos, c := range cells {
|
|
if c.char == "\n" {
|
|
appendCellsSinceLineStart(currentPos + 1)
|
|
y++
|
|
startOfLine = currentPos + 1
|
|
indexOfLastWhitespace = -1
|
|
currentLineWidth = 0
|
|
footNoteMatcher.reset()
|
|
trailerMatcher.reset()
|
|
} else {
|
|
currentLineWidth += c.width
|
|
if c.char == " " && !footNoteMatcher.isFootNote() && !trailerMatcher.isTrailer() {
|
|
indexOfLastWhitespace = currentPos + 1
|
|
} else if autoWrapWidth > 0 && currentLineWidth > autoWrapWidth && indexOfLastWhitespace >= 0 {
|
|
wrapAt := indexOfLastWhitespace
|
|
appendCellsSinceLineStart(wrapAt)
|
|
contentIndex := cells[wrapAt].contentIndex
|
|
y++
|
|
result = append(result, TextAreaCell{char: "\n", width: 1, contentIndex: contentIndex, x: 0, y: y})
|
|
softLineBreakIndices = append(softLineBreakIndices, contentIndex)
|
|
startOfLine = wrapAt
|
|
indexOfLastWhitespace = -1
|
|
currentLineWidth = 0
|
|
for _, c1 := range cells[startOfLine : currentPos+1] {
|
|
currentLineWidth += c1.width
|
|
}
|
|
footNoteMatcher.reset()
|
|
trailerMatcher.reset()
|
|
}
|
|
|
|
footNoteMatcher.addCharacter(c.char)
|
|
trailerMatcher.addCharacter(c.char)
|
|
}
|
|
}
|
|
|
|
appendCellsSinceLineStart(len(cells))
|
|
|
|
return result, softLineBreakIndices
|
|
}
|
|
|
|
var footNoteRe = regexp.MustCompile(`^\[\d+\]:\s*$`)
|
|
|
|
type footNoteMatcher struct {
|
|
lineStr strings.Builder
|
|
didFailToMatch bool
|
|
}
|
|
|
|
func (self *footNoteMatcher) addCharacter(chr string) {
|
|
if self.didFailToMatch {
|
|
// don't bother tracking the rune if we know it can't possibly match any more
|
|
return
|
|
}
|
|
|
|
if self.lineStr.Len() == 0 && chr != "[" {
|
|
// fail early if the first rune of a line isn't a '['; this is mainly to avoid a (possibly
|
|
// expensive) regex match
|
|
self.didFailToMatch = true
|
|
return
|
|
}
|
|
|
|
self.lineStr.WriteString(chr)
|
|
}
|
|
|
|
func (self *footNoteMatcher) isFootNote() bool {
|
|
if self.didFailToMatch {
|
|
return false
|
|
}
|
|
|
|
if footNoteRe.MatchString(self.lineStr.String()) {
|
|
// it's a footnote, so treat spaces as non-breaking. It's important not to reset the matcher
|
|
// here, because there could be multiple spaces after a footnote.
|
|
return true
|
|
}
|
|
|
|
// no need to check again for this line
|
|
self.didFailToMatch = true
|
|
return false
|
|
}
|
|
|
|
func (self *footNoteMatcher) reset() {
|
|
self.lineStr.Reset()
|
|
self.didFailToMatch = false
|
|
}
|
|
|
|
var supportedTrailers = []string{
|
|
"Signed-off-by:",
|
|
"Co-authored-by:",
|
|
}
|
|
|
|
type trailerMatcher struct {
|
|
lineStr strings.Builder
|
|
didFailToMatch bool
|
|
didMatch bool
|
|
}
|
|
|
|
func (self *trailerMatcher) addCharacter(chr string) {
|
|
if self.didFailToMatch || self.didMatch {
|
|
return
|
|
}
|
|
|
|
if len(chr) != 1 {
|
|
// Trailers are all ASCII, so if we get a non-ASCII UTF-8 character (or even a multi-rune
|
|
// grapheme cluster), we can fail early.
|
|
self.didFailToMatch = true
|
|
return
|
|
}
|
|
|
|
if self.lineStr.Len() == 0 {
|
|
// If this is the first character, see if it could possibly match any supported trailer; if
|
|
// not, we can fail early and stop tracking further characters for this line.
|
|
if !anyOf(supportedTrailers, func(trailer string) bool { return trailer[0] == chr[0] }) {
|
|
self.didFailToMatch = true
|
|
return
|
|
}
|
|
}
|
|
|
|
self.lineStr.WriteString(chr)
|
|
}
|
|
|
|
func (self *trailerMatcher) isTrailer() bool {
|
|
if self.didFailToMatch {
|
|
return false
|
|
}
|
|
|
|
if self.didMatch {
|
|
return true
|
|
}
|
|
|
|
line := self.lineStr.String()
|
|
if anyOf(supportedTrailers, func(trailer string) bool { return line == trailer }) {
|
|
self.didMatch = true
|
|
return true
|
|
}
|
|
|
|
self.didFailToMatch = true
|
|
return false
|
|
}
|
|
|
|
func (self *trailerMatcher) reset() {
|
|
self.lineStr.Reset()
|
|
self.didFailToMatch = false
|
|
self.didMatch = false
|
|
}
|
|
|
|
func anyOf(strings []string, predicate func(s string) bool) bool {
|
|
for _, s := range strings {
|
|
if predicate(s) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (self *TextArea) updateCells() {
|
|
width := self.AutoWrapWidth
|
|
if !self.AutoWrap {
|
|
width = -1
|
|
}
|
|
|
|
self.cells, _ = contentToCells(self.content, width)
|
|
}
|
|
|
|
func (self *TextArea) typeCharacter(ch string) {
|
|
widthToDelete := 0
|
|
if self.overwrite && !self.atEnd() {
|
|
s, _, _, _ := uniseg.FirstGraphemeClusterInString(self.content[self.cursor:], -1)
|
|
widthToDelete = len(s)
|
|
}
|
|
|
|
self.content = self.content[:self.cursor] + ch + self.content[self.cursor+widthToDelete:]
|
|
self.cursor += len(ch)
|
|
}
|
|
|
|
func (self *TextArea) TypeCharacter(ch string) {
|
|
self.typeCharacter(ch)
|
|
self.updateCells()
|
|
}
|
|
|
|
func (self *TextArea) BackSpaceChar() {
|
|
if self.cursor == 0 {
|
|
return
|
|
}
|
|
|
|
cellCursor := self.contentCursorToCellCursor(self.cursor)
|
|
widthToDelete := len(self.cells[cellCursor-1].char)
|
|
|
|
oldCursor := self.cursor
|
|
self.cursor -= widthToDelete
|
|
self.content = self.content[:self.cursor] + self.content[oldCursor:]
|
|
|
|
self.updateCells()
|
|
}
|
|
|
|
func (self *TextArea) DeleteChar() {
|
|
if self.atEnd() {
|
|
return
|
|
}
|
|
|
|
s, _, _, _ := uniseg.FirstGraphemeClusterInString(self.content[self.cursor:], -1)
|
|
widthToDelete := len(s)
|
|
self.content = self.content[:self.cursor] + self.content[self.cursor+widthToDelete:]
|
|
self.updateCells()
|
|
}
|
|
|
|
func (self *TextArea) MoveCursorLeft() {
|
|
if self.cursor == 0 {
|
|
return
|
|
}
|
|
|
|
cellCursor := self.contentCursorToCellCursor(self.cursor)
|
|
self.cursor -= len(self.cells[cellCursor-1].char)
|
|
}
|
|
|
|
func (self *TextArea) MoveCursorRight() {
|
|
if self.cursor == len(self.content) {
|
|
return
|
|
}
|
|
|
|
s, _, _, _ := uniseg.FirstGraphemeClusterInString(self.content[self.cursor:], -1)
|
|
self.cursor += len(s)
|
|
}
|
|
|
|
func (self *TextArea) newCursorForMoveLeftWord() int {
|
|
if self.cursor == 0 {
|
|
return 0
|
|
}
|
|
if self.atLineStart() {
|
|
return self.cursor - 1
|
|
}
|
|
|
|
cellCursor := self.contentCursorToCellCursor(self.cursor)
|
|
for cellCursor > 0 && (self.isSoftLineBreak(cellCursor-1) || strings.Contains(WHITESPACES, self.cells[cellCursor-1].char)) {
|
|
cellCursor--
|
|
}
|
|
separators := false
|
|
for cellCursor > 0 && strings.Contains(WORD_SEPARATORS, self.cells[cellCursor-1].char) {
|
|
cellCursor--
|
|
separators = true
|
|
}
|
|
if !separators {
|
|
for cellCursor > 0 && self.cells[cellCursor-1].char != "\n" && !strings.Contains(WHITESPACES+WORD_SEPARATORS, self.cells[cellCursor-1].char) {
|
|
cellCursor--
|
|
}
|
|
}
|
|
|
|
return self.cellCursorToContentCursor(cellCursor)
|
|
}
|
|
|
|
func (self *TextArea) MoveLeftWord() {
|
|
self.cursor = self.newCursorForMoveLeftWord()
|
|
}
|
|
|
|
func (self *TextArea) newCursorForMoveRightWord() int {
|
|
if self.atEnd() {
|
|
return self.cursor
|
|
}
|
|
if self.atLineEnd() {
|
|
return self.cursor + 1
|
|
}
|
|
|
|
cellCursor := self.contentCursorToCellCursor(self.cursor)
|
|
for cellCursor < len(self.cells) && (self.isSoftLineBreak(cellCursor) || strings.Contains(WHITESPACES, self.cells[cellCursor].char)) {
|
|
cellCursor++
|
|
}
|
|
separators := false
|
|
for cellCursor < len(self.cells) && strings.Contains(WORD_SEPARATORS, self.cells[cellCursor].char) {
|
|
cellCursor++
|
|
separators = true
|
|
}
|
|
if !separators {
|
|
for cellCursor < len(self.cells) && self.cells[cellCursor].char != "\n" && !strings.Contains(WHITESPACES+WORD_SEPARATORS, self.cells[cellCursor].char) {
|
|
cellCursor++
|
|
}
|
|
}
|
|
|
|
return self.cellCursorToContentCursor(cellCursor)
|
|
}
|
|
|
|
func (self *TextArea) MoveRightWord() {
|
|
self.cursor = self.newCursorForMoveRightWord()
|
|
}
|
|
|
|
func (self *TextArea) MoveCursorUp() {
|
|
x, y := self.GetCursorXY()
|
|
self.SetCursor2D(x, y-1)
|
|
}
|
|
|
|
func (self *TextArea) MoveCursorDown() {
|
|
x, y := self.GetCursorXY()
|
|
self.SetCursor2D(x, y+1)
|
|
}
|
|
|
|
func (self *TextArea) GetContent() string {
|
|
var b strings.Builder
|
|
for _, c := range self.cells {
|
|
b.WriteString(c.char)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (self *TextArea) GetUnwrappedContent() string {
|
|
return self.content
|
|
}
|
|
|
|
func (self *TextArea) ToggleOverwrite() {
|
|
self.overwrite = !self.overwrite
|
|
}
|
|
|
|
func (self *TextArea) atEnd() bool {
|
|
return self.cursor == len(self.content)
|
|
}
|
|
|
|
func (self *TextArea) DeleteToStartOfLine() {
|
|
// copying vim's logic: if you're at the start of the line, you delete the newline
|
|
// character and go to the end of the previous line
|
|
if self.atLineStart() {
|
|
if self.cursor == 0 {
|
|
return
|
|
}
|
|
|
|
self.content = self.content[:self.cursor-1] + self.content[self.cursor:]
|
|
self.cursor--
|
|
self.updateCells()
|
|
return
|
|
}
|
|
|
|
// otherwise, if we're at a soft line start, skip left past the soft line
|
|
// break, so we'll end up deleting the previous line. This seems like the
|
|
// only reasonable behavior in this case, as you can't delete just the soft
|
|
// line break.
|
|
if self.atSoftLineStart() {
|
|
self.cursor--
|
|
}
|
|
|
|
// otherwise, you delete everything up to the start of the current line, without
|
|
// deleting the newline character
|
|
newlineIndex := self.closestNewlineOnLeft()
|
|
self.clipboard = self.content[newlineIndex+1 : self.cursor]
|
|
self.content = self.content[:newlineIndex+1] + self.content[self.cursor:]
|
|
self.updateCells()
|
|
self.cursor = newlineIndex + 1
|
|
}
|
|
|
|
func (self *TextArea) DeleteToEndOfLine() {
|
|
if self.atEnd() {
|
|
return
|
|
}
|
|
|
|
// if we're at the end of the line, delete just the newline character
|
|
if self.atLineEnd() {
|
|
self.content = self.content[:self.cursor] + self.content[self.cursor+1:]
|
|
self.updateCells()
|
|
return
|
|
}
|
|
|
|
// otherwise, if we're at a soft line end, skip right past the soft line
|
|
// break, so we'll end up deleting the next line. This seems like the
|
|
// only reasonable behavior in this case, as you can't delete just the soft
|
|
// line break.
|
|
if self.atSoftLineEnd() {
|
|
self.cursor++
|
|
}
|
|
|
|
lineEndIndex := self.closestNewlineOnRight()
|
|
self.clipboard = self.content[self.cursor:lineEndIndex]
|
|
self.content = self.content[:self.cursor] + self.content[lineEndIndex:]
|
|
self.updateCells()
|
|
}
|
|
|
|
func (self *TextArea) GoToStartOfLine() {
|
|
if self.atSoftLineStart() {
|
|
return
|
|
}
|
|
|
|
newlineIndex := self.closestNewlineOnLeft()
|
|
self.cursor = newlineIndex + 1
|
|
}
|
|
|
|
func (self *TextArea) closestNewlineOnLeft() int {
|
|
cellCursor := self.contentCursorToCellCursor(self.cursor)
|
|
|
|
newlineCellIndex := -1
|
|
|
|
for i, c := range self.cells[0:cellCursor] {
|
|
if c.char == "\n" {
|
|
newlineCellIndex = i
|
|
}
|
|
}
|
|
|
|
if newlineCellIndex == -1 {
|
|
return -1
|
|
}
|
|
|
|
newlineContentIndex := self.cells[newlineCellIndex].contentIndex
|
|
if self.content[newlineContentIndex] != '\n' {
|
|
newlineContentIndex--
|
|
}
|
|
return newlineContentIndex
|
|
}
|
|
|
|
func (self *TextArea) GoToEndOfLine() {
|
|
if self.atEnd() {
|
|
return
|
|
}
|
|
|
|
self.cursor = self.closestNewlineOnRight()
|
|
|
|
self.moveLeftFromSoftLineBreak()
|
|
}
|
|
|
|
func (self *TextArea) closestNewlineOnRight() int {
|
|
cellCursor := self.contentCursorToCellCursor(self.cursor)
|
|
|
|
for i, c := range self.cells[cellCursor:] {
|
|
if c.char == "\n" {
|
|
return self.cellCursorToContentCursor(cellCursor + i)
|
|
}
|
|
}
|
|
|
|
return len(self.content)
|
|
}
|
|
|
|
func (self *TextArea) moveLeftFromSoftLineBreak() {
|
|
// If the end of line is a soft line break, we need to move left by one so
|
|
// that we end up at the last whitespace before the line break. Otherwise
|
|
// we'd be at the start of the next line, since the newline character
|
|
// doesn't really exist in the real content.
|
|
if self.cursor < len(self.content) && self.content[self.cursor] != '\n' {
|
|
self.cursor--
|
|
}
|
|
}
|
|
|
|
func (self *TextArea) atLineStart() bool {
|
|
return self.cursor == 0 ||
|
|
(len(self.content) > self.cursor-1 && self.content[self.cursor-1] == '\n')
|
|
}
|
|
|
|
func (self *TextArea) isSoftLineBreak(cellCursor int) bool {
|
|
cell := self.cells[cellCursor]
|
|
return cell.char == "\n" && self.content[cell.contentIndex] != '\n'
|
|
}
|
|
|
|
func (self *TextArea) atSoftLineStart() bool {
|
|
cellCursor := self.contentCursorToCellCursor(self.cursor)
|
|
return cellCursor == 0 ||
|
|
(len(self.cells) > cellCursor-1 && self.cells[cellCursor-1].char == "\n")
|
|
}
|
|
|
|
func (self *TextArea) atLineEnd() bool {
|
|
return self.atEnd() ||
|
|
(len(self.content) > self.cursor && self.content[self.cursor] == '\n')
|
|
}
|
|
|
|
func (self *TextArea) atSoftLineEnd() bool {
|
|
cellCursor := self.contentCursorToCellCursor(self.cursor)
|
|
return cellCursor == len(self.cells) ||
|
|
(len(self.cells) > cellCursor+1 && self.cells[cellCursor+1].char == "\n")
|
|
}
|
|
|
|
func (self *TextArea) BackSpaceWord() {
|
|
newCursor := self.newCursorForMoveLeftWord()
|
|
if newCursor == self.cursor {
|
|
return
|
|
}
|
|
|
|
clipboard := self.content[newCursor:self.cursor]
|
|
if clipboard != "\n" {
|
|
self.clipboard = clipboard
|
|
}
|
|
self.content = self.content[:newCursor] + self.content[self.cursor:]
|
|
self.cursor = newCursor
|
|
self.updateCells()
|
|
}
|
|
|
|
func (self *TextArea) ForwardDeleteWord() {
|
|
newCursor := self.newCursorForMoveRightWord()
|
|
if newCursor == self.cursor {
|
|
return
|
|
}
|
|
|
|
clipboard := self.content[self.cursor:newCursor]
|
|
if clipboard != "\n" {
|
|
self.clipboard = clipboard
|
|
}
|
|
self.content = self.content[:self.cursor] + self.content[newCursor:]
|
|
self.updateCells()
|
|
}
|
|
|
|
func (self *TextArea) Yank() {
|
|
self.TypeString(self.clipboard)
|
|
}
|
|
|
|
func (self *TextArea) contentCursorToCellCursor(origCursor int) int {
|
|
idx, _ := slices.BinarySearchFunc(self.cells, origCursor, func(cell TextAreaCell, cursor int) int {
|
|
return cell.contentIndex - cursor
|
|
})
|
|
for idx < len(self.cells)-1 && self.cells[idx+1].contentIndex == origCursor {
|
|
idx++
|
|
}
|
|
return idx
|
|
}
|
|
|
|
func (self *TextArea) cellCursorToContentCursor(cellCursor int) int {
|
|
if cellCursor >= len(self.cells) {
|
|
return len(self.content)
|
|
}
|
|
|
|
return self.cells[cellCursor].contentIndex
|
|
}
|
|
|
|
func (self *TextArea) GetCursorXY() (int, int) {
|
|
if len(self.cells) == 0 {
|
|
return 0, 0
|
|
}
|
|
cellCursor := self.contentCursorToCellCursor(self.cursor)
|
|
if cellCursor >= len(self.cells) {
|
|
return self.cells[len(self.cells)-1].nextCursorXY()
|
|
}
|
|
if cellCursor > 0 && self.cells[cellCursor].char == "\n" {
|
|
return self.cells[cellCursor-1].nextCursorXY()
|
|
}
|
|
cell := self.cells[cellCursor]
|
|
return cell.x, cell.y
|
|
}
|
|
|
|
// takes an x,y position and maps it to a 1D cursor position
|
|
func (self *TextArea) SetCursor2D(x int, y int) {
|
|
if y < 0 {
|
|
y = 0
|
|
}
|
|
if x < 0 {
|
|
x = 0
|
|
}
|
|
|
|
newCursor := 0
|
|
for _, c := range self.cells {
|
|
if x <= 0 && y == 0 {
|
|
self.cursor = self.cellCursorToContentCursor(newCursor)
|
|
if self.cells[newCursor].char == "\n" {
|
|
self.moveLeftFromSoftLineBreak()
|
|
}
|
|
return
|
|
}
|
|
|
|
if c.char == "\n" {
|
|
if y == 0 {
|
|
self.cursor = self.cellCursorToContentCursor(newCursor)
|
|
self.moveLeftFromSoftLineBreak()
|
|
return
|
|
}
|
|
y--
|
|
} else if y == 0 {
|
|
x -= c.width
|
|
}
|
|
|
|
newCursor++
|
|
}
|
|
|
|
// if we weren't able to run-down our arg, the user is trying to move out of
|
|
// bounds so we'll just return
|
|
if y > 0 {
|
|
return
|
|
}
|
|
|
|
self.cursor = self.cellCursorToContentCursor(newCursor)
|
|
}
|
|
|
|
func (self *TextArea) Clear() {
|
|
self.content = ""
|
|
self.cells = nil
|
|
self.cursor = 0
|
|
}
|
|
|
|
func (self *TextArea) TypeString(str string) {
|
|
state := -1
|
|
for str != "" {
|
|
var chr string
|
|
chr, str, _, state = uniseg.FirstGraphemeClusterInString(str, state)
|
|
self.typeCharacter(chr)
|
|
}
|
|
|
|
self.updateCells()
|
|
}
|