mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
I copied all files except dot files (.github and .gitignore), the _examples folder, and go.mod/go.sum. At some point we may want to copy the files back to the gocui repo when other clients (e.g. lazydocker) want to use the newer versions of them.
657 lines
16 KiB
Go
657 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) MoveRightWord() {
|
|
if self.atEnd() {
|
|
return
|
|
}
|
|
if self.atLineEnd() {
|
|
self.cursor++
|
|
return
|
|
}
|
|
|
|
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++
|
|
}
|
|
}
|
|
|
|
self.cursor = self.cellCursorToContentCursor(cellCursor)
|
|
}
|
|
|
|
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) 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()
|
|
}
|