From 40e1b40672877a267e78086cde8657b74213c0d7 Mon Sep 17 00:00:00 2001 From: "Luke I. Wilson" Date: Tue, 23 Mar 2021 15:14:23 -0500 Subject: [PATCH] Add and replace []string buffer with ropes --- main.go | 24 +++-- ui/buffer/buffer.go | 18 +++- ui/buffer/rope.go | 149 ++++++++++++++++++++-------- ui/buffer/rope_test.go | 38 ++++++++ ui/textedit.go | 216 +++++++++++------------------------------ 5 files changed, 228 insertions(+), 217 deletions(-) diff --git a/main.go b/main.go index a7b8a50..5faa9b1 100644 --- a/main.go +++ b/main.go @@ -77,7 +77,7 @@ func main() { } } - textEdit := ui.NewTextEdit(&s, os.Args[i], string(bytes), &theme) + textEdit := ui.NewTextEdit(&s, os.Args[i], bytes, &theme) textEdit.Dirty = dirty tabContainer.AddTab(os.Args[i], textEdit) } @@ -88,7 +88,7 @@ func main() { fileMenu := ui.NewMenu("_File", &theme) fileMenu.AddItems([]ui.Item{&ui.ItemEntry{Name: "_New File", Shortcut: "Ctrl+N", Callback: func() { - textEdit := ui.NewTextEdit(&s, "", "", &theme) // No file path, no contents + textEdit := ui.NewTextEdit(&s, "", []byte{}, &theme) // No file path, no contents tabContainer.AddTab("noname", textEdit) }}, &ui.ItemEntry{Name: "_Open...", Shortcut: "Ctrl+O", Callback: func() { callback := func(filePaths []string) { @@ -104,7 +104,7 @@ func main() { panic("Could not read all of file") } - textEdit := ui.NewTextEdit(&s, path, string(bytes), &theme) + textEdit := ui.NewTextEdit(&s, path, bytes, &theme) tabContainer.AddTab(path, textEdit) } // TODO: free the dialog instead? @@ -129,14 +129,12 @@ func main() { tab := tabContainer.GetTab(tabContainer.GetSelectedTabIdx()) te := tab.Child.(*ui.TextEdit) if len(te.FilePath) > 0 { - contents := te.String() - // Write the contents into the file, creating one if it does // not exist. - err := ioutil.WriteFile(te.FilePath, []byte(contents), fs.ModePerm) + err := ioutil.WriteFile(te.FilePath, te.Buffer.Bytes(), fs.ModePerm) if err != nil { panic("Could not write file at path " + te.FilePath) - } + } // TODO: Replace with io.Writer method te.Dirty = false } @@ -202,11 +200,11 @@ func main() { if tabContainer.GetTabCount() > 0 { tab := tabContainer.GetTab(tabContainer.GetSelectedTabIdx()) te := tab.Child.(*ui.TextEdit) - selectedStr := te.GetSelectedString() - if selectedStr != "" { // If something is selected... + bytes := te.GetSelectedBytes() + if len(bytes) > 0 { // If something is selected... te.Delete(false) // Delete the selection // TODO: better error handling within editor - _ = ClipWrite(selectedStr) // Add the selectedStr to clipboard + _ = ClipWrite(string(bytes)) // Add the selectedStr to clipboard } changeFocus(tabContainer) } @@ -214,9 +212,9 @@ func main() { if tabContainer.GetTabCount() > 0 { tab := tabContainer.GetTab(tabContainer.GetSelectedTabIdx()) te := tab.Child.(*ui.TextEdit) - selectedStr := te.GetSelectedString() - if selectedStr != "" { // If there is something selected... - _ = ClipWrite(selectedStr) // Add selectedStr to clipboard + bytes := te.GetSelectedBytes() + if len(bytes) > 0 { // If there is something selected... + _ = ClipWrite(string(bytes)) // Add selectedStr to clipboard } changeFocus(tabContainer) } diff --git a/ui/buffer/buffer.go b/ui/buffer/buffer.go index 02f9d63..cdb2692 100644 --- a/ui/buffer/buffer.go +++ b/ui/buffer/buffer.go @@ -35,6 +35,10 @@ type Buffer interface { // endCol, inclusive bounds. Remove(startLine, startCol, endLine, endCol int) + // Returns the number of occurrences of 'sequence' in the buffer, within the range + // of start line and col, to end line and col. [start, end) (exclusive end). + Count(startLine, startCol, endLine, endCol int, sequence []byte) int + // Len returns the number of bytes in the buffer. Len() int @@ -43,13 +47,19 @@ type Buffer interface { // basically counts the number of newline ('\n') characters in a buffer. Lines() int - // ColsInLine returns the number of columns in the given line. That is, the - // number of Utf-8 codepoints (or runes) in line, not bytes. + // RunesInLine returns the number of runes in the given line. That is, the + // number of Utf-8 codepoints in the line, not bytes. Includes the line delimiter + // in the count. If that line delimiter is CRLF ('\r\n'), then it adds two. + RunesInLineWithDelim(line int) int + + // RunesInLine returns the number of runes in the given line. That is, the + // number of Utf-8 codepoints in the line, not bytes. Excludes line delimiters. RunesInLine(line int) int // ClampLineCol is a utility function to clamp any provided line and col to - // only possible values within the buffer. It first clamps the line, then clamps - // the column within that line. + // only possible values within the buffer, pointing to runes. It first clamps + // the line, then clamps the column. The column is clamped between zero and + // the last rune before the line delimiter. ClampLineCol(line, col int) (int, int) WriteTo(w io.Writer) (int64, error) diff --git a/ui/buffer/rope.go b/ui/buffer/rope.go index e5c991f..f8855ce 100644 --- a/ui/buffer/rope.go +++ b/ui/buffer/rope.go @@ -63,13 +63,22 @@ func (b *RopeBuffer) Line(line int) []byte { _, r := _rope.SplitAt(pos) l, _ := r.SplitAt(_rope.Len() - pos) + var isCRLF bool // true if the last byte was '\r' l.EachLeaf(func(n *rope.Node) bool { data := n.Value() // Reference; not a copy. var i int for i < len(data) { - if data[i] == '\n' { - bytes++ // Add the newline byte + if data[i] == '\r' { + isCRLF = true + } else if data[i] == '\n' { + if isCRLF { + bytes += 2 // Add the CRLF bytes + } else { + bytes += 1 // Add LF byte + } return true // Read (past-tense) the whole line + } else { + isCRLF = false } // Respect Utf-8 codepoint boundaries @@ -108,6 +117,14 @@ func (b *RopeBuffer) Remove(startLine, startCol, endLine, endCol int) { (*rope.Node)(b).Remove(b.pos(startLine, startCol), b.pos(endLine, endCol)+1) } +// Returns the number of occurrences of 'sequence' in the buffer, within the range +// of start line and col, to end line and col. End is exclusive. +func (b *RopeBuffer) Count(startLine, startCol, endLine, endCol int, sequence []byte) int { + startPos := b.pos(startLine, startCol) + endPos := b.pos(endLine, endCol) + return (*rope.Node)(b).Count(startPos, endPos, sequence) +} + // Len returns the number of bytes in the buffer. func (b *RopeBuffer) Len() int { return (*rope.Node)(b).Len() @@ -121,41 +138,6 @@ func (b *RopeBuffer) Lines() int { return rope.Count(0, rope.Len(), []byte{'\n'}) + 1 } -// pos is the first byte index in the line we want to count runes/cols. pos can point to -// a newline or be greater than or equal to the number of bytes in the buffer, and will -// return 0 for both cases. -func (b *RopeBuffer) runesInLine(pos int) int { - _rope := (*rope.Node)(b) - ropeLen := _rope.Len() - - if pos >= ropeLen { - return 0 - } - - var count int - - _, r := _rope.SplitAt(pos) - l, _ := r.SplitAt(ropeLen - pos) - - l.EachLeaf(func(n *rope.Node) bool { - data := n.Value() // Reference; not a copy. - var i int - for i < len(data) { - if data[i] == '\n' { - return true // Read (past-tense) the whole line - } - count++ - - // Respect Utf-8 codepoint boundaries - _, size := utf8.DecodeRune(data[i:]) - i += size - } - return false // Have not read the whole line, yet - }) - - return count -} - // getLineStartPos returns the first byte index of the given line (starting from zero). // The returned index can be equal to the length of the buffer, not pointing to any byte, // which means the byte is on the last, and empty, line of the buffer. If line is greater @@ -182,16 +164,99 @@ func (b *RopeBuffer) getLineStartPos(line int) int { return pos } +// RunesInLineWithDelim returns the number of runes in the given line. That is, the +// number of Utf-8 codepoints in the line, not bytes. Includes the line delimiter +// in the count. If that line delimiter is CRLF ('\r\n'), then it adds two. +func (b *RopeBuffer) RunesInLineWithDelim(line int) int { + linePos := b.getLineStartPos(line) + + _rope := (*rope.Node)(b) + ropeLen := _rope.Len() + + if linePos >= ropeLen { + return 0 + } + + var count int + + _, r := _rope.SplitAt(linePos) + l, _ := r.SplitAt(ropeLen - linePos) + + var isCRLF bool + l.EachLeaf(func(n *rope.Node) bool { + data := n.Value() // Reference; not a copy. + var i int + for i < len(data) { + count++ // Before: we count the line delimiter + if data[i] == '\r' { + isCRLF = true + } else if data[i] == '\n' { + return true // Read (past-tense) the whole line + } else { + if isCRLF { + isCRLF = false + count++ // Add the '\r' we previously thought was part of the delim. + } + } + + // Respect Utf-8 codepoint boundaries + _, size := utf8.DecodeRune(data[i:]) + i += size + } + return false // Have not read the whole line, yet + }) + + return count +} + // RunesInLine returns the number of runes in the given line. That is, the -// number of Utf-8 codepoints in the line, not bytes. +// number of Utf-8 codepoints in the line, not bytes. Excludes line delimiters. func (b *RopeBuffer) RunesInLine(line int) int { linePos := b.getLineStartPos(line) - return b.runesInLine(linePos) + + _rope := (*rope.Node)(b) + ropeLen := _rope.Len() + + if linePos >= ropeLen { + return 0 + } + + var count int + + _, r := _rope.SplitAt(linePos) + l, _ := r.SplitAt(ropeLen - linePos) + + var isCRLF bool + l.EachLeaf(func(n *rope.Node) bool { + data := n.Value() // Reference; not a copy. + var i int + for i < len(data) { + if data[i] == '\r' { + isCRLF = true + } else if data[i] == '\n' { + return true // Read (past-tense) the whole line + } else { + if isCRLF { + isCRLF = false + count++ // Add the '\r' we previously thought was part of the delim. + } + } + count++ + + // Respect Utf-8 codepoint boundaries + _, size := utf8.DecodeRune(data[i:]) + i += size + } + return false // Have not read the whole line, yet + }) + + return count } // ClampLineCol is a utility function to clamp any provided line and col to // only possible values within the buffer, pointing to runes. It first clamps -// the line, then clamps the column. +// the line, then clamps the column. The column is clamped between zero and +// the last rune before the line delimiter. func (b *RopeBuffer) ClampLineCol(line, col int) (int, int) { if line < 0 { line = 0 @@ -201,8 +266,8 @@ func (b *RopeBuffer) ClampLineCol(line, col int) (int, int) { if col < 0 { col = 0 - } else if cols := b.RunesInLine(line)-1; col > cols { - col = cols + } else if runes := b.RunesInLine(line); col > runes { + col = runes } return line, col diff --git a/ui/buffer/rope_test.go b/ui/buffer/rope_test.go index 1fa8aca..0b3d46c 100644 --- a/ui/buffer/rope_test.go +++ b/ui/buffer/rope_test.go @@ -34,14 +34,52 @@ func TestRopeBounds(t *testing.T) { t.Fail() } + if len := buf.RunesInLineWithDelim(4); len != 0 { + t.Errorf("Expected 0 runes in line 5, found %v", len) + t.Fail() + } + line, col := buf.ClampLineCol(15, 5) // Should become last line, first column if line != 4 && col != 0 { t.Errorf("Expected to clamp line col to 4,0 got %v,%v", line, col) t.Fail() } + line, col = buf.ClampLineCol(4, -1) + if line != 4 && col != 0 { + t.Errorf("Expected to clamp line col to 4,0 got %v,%v", line, col) + t.Fail() + } + + line, col = buf.ClampLineCol(2, 5) // Should be third line, pointing at the newline char + if line != 2 && col != 5 { + t.Errorf("Expected to clamp line, col to 2,5 got %v,%v", line, col) + t.Fail() + } + if line := string(buf.Line(2)); line != "\tsome\n" { t.Errorf("Expected line 3 to equal \"\\tsome\", got %#v", line) t.Fail() } + + if line := string(buf.Line(4)); line != "" { + t.Errorf("Got %#v", line) + t.Fail() + } +} + +func TestRopeCount(t *testing.T) { + var buf Buffer = NewRopeBuffer([]byte("\t\tlot of\n\ttabs")) + + tabsAtOf := buf.Count(0, 0, 0, 7, []byte{'\t'}) + if tabsAtOf != 2 { + t.Errorf("Expected 2 tabs before 'of', got %#v", tabsAtOf) + t.Fail() + } + + tabs := buf.Count(0, 0, 0, 0, []byte{'\t'}) + if tabs != 0 { + t.Errorf("Expected no tabs at column zero, got %v", tabs) + t.Fail() + } } diff --git a/ui/textedit.go b/ui/textedit.go index 7cec23d..c825fca 100644 --- a/ui/textedit.go +++ b/ui/textedit.go @@ -48,9 +48,9 @@ type TextEdit struct { Theme *Theme } -// New will initialize the buffer using the given string `contents`. If the `filePath` or `FilePath` is empty, +// New will initialize the buffer using the given 'contents'. If the 'filePath' or 'FilePath' is empty, // it can be assumed that the TextEdit has no file association, or it is unsaved. -func NewTextEdit(screen *tcell.Screen, filePath, contents string, theme *Theme) *TextEdit { +func NewTextEdit(screen *tcell.Screen, filePath string, contents []byte, theme *Theme) *TextEdit { te := &TextEdit{ Buffer: nil, LineNumbers: true, @@ -66,11 +66,11 @@ func NewTextEdit(screen *tcell.Screen, filePath, contents string, theme *Theme) // SetContents applies the string to the internal buffer of the TextEdit component. // The string is determined to be either CRLF or LF based on line-endings. -func (t *TextEdit) SetContents(contents string) { +func (t *TextEdit) SetContents(contents []byte) { var i int loop: for i < len(contents) { - switch r { + switch contents[i] { case '\n': t.IsCRLF = false break loop @@ -79,7 +79,8 @@ loop: t.IsCRLF = true break loop } - i += utf8.DecodeRune(contents[i:]) + _, size := utf8.DecodeRune(contents[i:]) + i += size } t.Buffer = buffer.NewRopeBuffer(contents) @@ -94,10 +95,6 @@ func (t *TextEdit) GetLineDelimiter() string { } } -func (t *TextEdit) String() string { - return strings.Join(t.buffer, t.GetLineDelimiter()) -} - // Changes a file's line delimiters. If `crlf` is true, then line delimiters are replaced // with Windows CRLF (\r\n). If `crlf` is false, then line delimtiers are replaced with Unix // LF (\n). The TextEdit `IsCRLF` variable is updated with the new value. @@ -105,6 +102,8 @@ func (t *TextEdit) ChangeLineDelimiters(crlf bool) { t.IsCRLF = crlf t.Dirty = true // line delimiters are constructed with String() function + // TODO: ^ not true anymore ^ + panic("Cannot ChangeLineDelimiters") } // Delete with `forwards` false will backspace, destroying the character before the cursor, @@ -114,59 +113,26 @@ func (t *TextEdit) Delete(forwards bool) { t.Dirty = true if t.selectMode { // If text is selected, delete the whole selection - t.cury, t.curx = t.clampLineCol(t.selection.EndLine, t.selection.EndCol) + t.cury, t.curx = t.Buffer.ClampLineCol(t.selection.EndLine, t.selection.EndCol) t.selectMode = false // Disable selection and prevent infinite loop - t.Delete(true) // Delete last character of selection first - // Delete from end, backwards, until we are at the start of the selection - for { // TODO: inefficient - if t.cury == t.selection.StartLine && t.curx == t.selection.StartCol { - break - } - t.Delete(false) // NOTE: we want to delete start column as well. - } + // Delete the region + t.Buffer.Remove(t.selection.StartLine, t.selection.StartCol, t.selection.EndLine, t.selection.EndCol) + t.SetLineCol(t.selection.StartLine, t.selection.StartCol) // Set cursor to start of region + return } - // TODO: deleting through lines if forwards { // Delete the character after the cursor - if t.curx < len(t.buffer[t.cury]) { // If the cursor is not at the end of the line... - lineRunes := []rune(t.buffer[t.cury]) - copy(lineRunes[t.curx:], lineRunes[t.curx+1:]) // Shift runes at cursor + 1 left - lineRunes = lineRunes[:len(lineRunes)-1] // Shrink line length - t.buffer[t.cury] = string(lineRunes) // Reassign line - } else { // If the cursor is at the end of the line... - if t.cury < len(t.buffer)-1 { // And the cursor is not at the last line... - oldLineIdx := t.cury + 1 - curLineRunes := []rune(t.buffer[t.cury]) - oldLineRunes := []rune(t.buffer[oldLineIdx]) - curLineRunes = append(curLineRunes, oldLineRunes...) // Append runes from deleted line to current line - t.buffer[t.cury] = string(curLineRunes) // Update the current line with the new runes - - copy(t.buffer[oldLineIdx:], t.buffer[oldLineIdx+1:]) // Shift lines below the old line up - t.buffer = t.buffer[:len(t.buffer)-1] // Shrink buffer by one line - } + // If the cursor is not at the end of the last line... + if t.cury < t.Buffer.Lines()-1 || t.curx < t.Buffer.RunesInLine(t.cury) { + t.Buffer.Remove(t.cury, t.curx, t.cury, t.curx) // Remove character at cursor } } else { // Delete the character before the cursor - if t.curx > 0 { // If the cursor is not at the beginning of the line... - lineRunes := []rune(t.buffer[t.cury]) - copy(lineRunes[t.curx-1:], lineRunes[t.curx:]) // Shift runes at cursor left - lineRunes = lineRunes[:len(lineRunes)-1] // Shrink line length - t.buffer[t.cury] = string(lineRunes) // Reassign line - - t.SetLineCol(t.cury, t.curx-1) // Shift cursor left - } else { // If the cursor is at the beginning of the line... - if t.cury > 0 { // And the cursor is not at the first line... - oldLineIdx := t.cury - t.SetLineCol(t.cury-1, len(t.buffer[t.cury-1])) // Cursor goes to the end of the above line - curLineRunes := []rune(t.buffer[t.cury]) - oldLineRunes := []rune(t.buffer[oldLineIdx]) - curLineRunes = append(curLineRunes, oldLineRunes...) // Append the old line to the current line - t.buffer[t.cury] = string(curLineRunes) // Update the current line to the new runes - - copy(t.buffer[oldLineIdx:], t.buffer[oldLineIdx+1:]) // Shift lines below the old line up - t.buffer = t.buffer[:len(t.buffer)-1] // Shrink buffer by one line - } + // If the cursor is not at the first column of the first line... + if t.cury > 0 || t.curx > 0 { + t.CursorLeft() // Back up to that character + t.Buffer.Remove(t.cury, t.curx, t.cury, t.curx) // Remove character at cursor } } } @@ -188,99 +154,45 @@ func (t *TextEdit) Insert(contents string) { case '\r': // If the character after is a \n, then it is a CRLF if i+1 < len(runes) && runes[i+1] == '\n' { - i++ - t.insertNewLine() + i++ // Consume '\n' after + t.Buffer.Insert(t.cury, t.curx, []byte{'\n'}) + t.SetLineCol(t.cury+1, 0) // Go to the start of that new line } case '\n': - t.insertNewLine() + t.Buffer.Insert(t.cury, t.curx, []byte{'\n'}) + t.SetLineCol(t.cury+1, 0) // Go to the start of that new line case '\b': t.Delete(false) // Delete the character before the cursor case '\t': if !t.UseHardTabs { // If this file does not use hard tabs... // Insert spaces - spaces := []rune(strings.Repeat(" ", t.TabSize)) - spacesLen := len(spaces) - - lineRunes := []rune(t.buffer[t.cury]) - lineRunes = append(lineRunes, spaces...) - copy(lineRunes[t.curx+spacesLen:], lineRunes[t.curx:]) // Shift runes at cursor to the right - copy(lineRunes[t.curx:], spaces) // Copy spaces into the gap - - t.buffer[t.cury] = string(lineRunes) // Reassign the line - - t.SetLineCol(t.cury, t.curx+spacesLen) // Advance the cursor + spaces := strings.Repeat(" ", t.TabSize) + t.Buffer.Insert(t.cury, t.curx, []byte(spaces)) + t.SetLineCol(t.cury, t.curx+len(spaces)) // Advance the cursor break } fallthrough // Append the \t character default: // Insert character into line - lineRunes := []rune(t.buffer[t.cury]) - lineRunes = append(lineRunes, ch) // Extend the length of the string - copy(lineRunes[t.curx+1:], lineRunes[t.curx:]) // Shift runes at cursor to the right - lineRunes[t.curx] = ch - - t.buffer[t.cury] = string(lineRunes) // Reassign the line - + t.Buffer.Insert(t.cury, t.curx, []byte(string(ch))) t.SetLineCol(t.cury, t.curx+1) // Advance the cursor } } t.prevCurCol = t.curx } -// insertNewLine inserts a line break at the cursor and sets the cursor position to the first -// column of that new line. Text before the cursor on the current line remains on that line, -// text at or after the cursor on the current line is moved to the new line. -func (t *TextEdit) insertNewLine() { - t.Dirty = true - - lineRunes := []rune(t.buffer[t.cury]) // A slice of runes of the old line - movedRunes := lineRunes[t.curx:] // A slice of the old line containing runes to be moved - newLineRunes := make([]rune, len(movedRunes)) - copy(newLineRunes, movedRunes) // Copy old runes to new line - t.buffer[t.cury] = string(lineRunes[:t.curx]) // Shrink old line's length - - t.buffer = append(t.buffer, "") // Increment buffer length - copy(t.buffer[t.cury+2:], t.buffer[t.cury+1:]) // Shift lines after current line down - t.buffer[t.cury+1] = string(newLineRunes) // Assign the new line - - t.SetLineCol(t.cury+1, 0) // Go to start of new line - t.prevCurCol = t.curx -} - -// getTabCountInLineAtCol returns tabs in the given line, at or before that column position, +// getTabCountInLineAtCol returns tabs in the given line, before the column position, // if hard tabs are enabled. If hard tabs are not enabled, the function returns zero. // Multiply returned tab count by TabSize to get the offset produced by tabs. // Col must be a valid column position in the given line. Maybe call clampLineCol before // this function. func (t *TextEdit) getTabCountInLineAtCol(line, col int) int { if t.UseHardTabs { - lineRunes := []rune(t.buffer[line]) - return strings.Count(string(lineRunes[:col]), "\t") + return t.Buffer.Count(line, 0, line, col, []byte{'\t'}) } return 0 } -// clampLineCol clamps the line and col inputs to only valid values within the buffer. -func (t *TextEdit) clampLineCol(line, col int) (int, int) { - // Clamp the line input - if line < 0 { - line = 0 - } else if len := len(t.buffer); line >= len { // If new line is beyond the length of the buffer... - line = len - 1 // Change that line to be the end of the buffer, instead - } - - lineRunes := []rune(t.buffer[line]) - - // Clamp the column input - if col < 0 { - col = 0 - } else if len := len(lineRunes); col > len { - col = len - } - - return line, col -} - // GetLineCol returns (line, col) of the cursor. Zero is origin for both. func (t *TextEdit) GetLineCol() (int, int) { return t.cury, t.curx @@ -291,10 +203,10 @@ func (t *TextEdit) GetLineCol() (int, int) { // If `col` is out of bounds, `col` will be clamped to the closest column available for the line. // Will scroll the TextEdit just enough to see the line the cursor is at. func (t *TextEdit) SetLineCol(line, col int) { - line, col = t.clampLineCol(line, col) + line, col = t.Buffer.ClampLineCol(line, col) // Handle hard tabs - tabOffset := t.getTabCountInLineAtCol(line, col) * (t.TabSize - 1) // Offset for the current line from hard tabs (temporary; purely visual) + tabOffset := t.getTabCountInLineAtCol(line, col) * (t.TabSize-1) // Offset for the current line from hard tabs (temporary; purely visual) // Scroll the screen when going to lines out of view if line >= t.scrolly+t.height-1 { // If the new line is below view... @@ -312,6 +224,10 @@ func (t *TextEdit) SetLineCol(line, col int) { t.scrollx = col + tabOffset // Scroll left enough to view that column } + if t.scrollx < 0 { + panic("oops") + } + t.cury, t.curx = line, col if t.focused && !t.selectMode { (*t.screen).ShowCursor(t.x+columnWidth+col+tabOffset-t.scrollx, t.y+line-t.scrolly) @@ -325,7 +241,7 @@ func (t *TextEdit) CursorUp() { if t.cury <= 0 { // If the cursor is at the first line... t.SetLineCol(t.cury, 0) // Go to beginning } else { - line, col := t.clampLineCol(t.cury-1, t.prevCurCol) + line, col := t.Buffer.ClampLineCol(t.cury-1, t.prevCurCol) if t.UseHardTabs { // When using hard tabs, subtract offsets produced by tabs tabOffset := t.getTabCountInLineAtCol(line, col) * (t.TabSize - 1) col -= tabOffset // We still count each \t in the col @@ -336,10 +252,10 @@ func (t *TextEdit) CursorUp() { // CursorDown moves the cursor down a line. func (t *TextEdit) CursorDown() { - if t.cury >= len(t.buffer)-1 { // If the cursor is at the last line... - t.SetLineCol(t.cury, len(t.buffer[t.cury])) // Go to end of current line + if t.cury >= t.Buffer.Lines()-1 { // If the cursor is at the last line... + t.SetLineCol(t.cury, math.MaxInt32) // Go to end of current line } else { - line, col := t.clampLineCol(t.cury+1, t.prevCurCol) + line, col := t.Buffer.ClampLineCol(t.cury+1, t.prevCurCol) if t.UseHardTabs { tabOffset := t.getTabCountInLineAtCol(line, col) * (t.TabSize - 1) col -= tabOffset // We still count each \t in the col @@ -363,7 +279,7 @@ func (t *TextEdit) CursorLeft() { func (t *TextEdit) CursorRight() { // If we are at the end of the current line, // and not at the last line... - if t.curx >= len([]rune(t.buffer[t.cury])) && t.cury < len(t.buffer)-1 { + if t.curx >= t.Buffer.RunesInLine(t.cury) && t.cury < t.Buffer.Lines()-1 { t.SetLineCol(t.cury+1, 0) // Go to beginning of line below } else { t.SetLineCol(t.cury, t.curx+1) @@ -377,39 +293,25 @@ func (t *TextEdit) getColumnWidth() int { columnWidth := 0 if t.LineNumbers { // Set columnWidth to max count of line number digits - columnWidth = Max(2, len(strconv.Itoa(len(t.buffer)))) // Column has minimum width of 2 + columnWidth = Max(2, len(strconv.Itoa(t.Buffer.Lines()))) // Column has minimum width of 2 } return columnWidth } -// GetSelectedString returns a string of the region of the buffer that is currently selected. -// If the returned string is empty, then nothing was selected. -func (t *TextEdit) GetSelectedString() string { +// GetSelectedBytes returns a byte slice of the region of the buffer that is currently selected. +// If the returned string is empty, then nothing was selected. The slice returned may or may not +// be a copy of the buffer, so do not write to it. +func (t *TextEdit) GetSelectedBytes() []byte { if t.selectMode { - lines := make([]string, t.selection.EndLine-t.selection.StartLine+1) - copy(lines, t.buffer[t.selection.StartLine:t.selection.EndLine+1]) - - // Start last line at end col - lastLine := lines[len(lines)-1] - if t.selection.EndCol >= len(lastLine) { // If the line delimiter of the last line is selected... - // Don't access out-of-bounds and include the line delimiter - lastLine = string([]rune(lastLine)[:t.selection.EndCol]) + t.GetLineDelimiter() - } else { // Normal access - lastLine = string([]rune(lastLine)[:t.selection.EndCol+1]) - } - lines[len(lines)-1] = lastLine - - lines[0] = string([]rune(lines[0])[t.selection.StartCol:]) // Start first line at start col - - return strings.Join(lines, t.GetLineDelimiter()) + return t.Buffer.Slice(t.selection.StartLine, t.selection.StartCol, t.selection.EndLine, t.selection.EndCol) } - return "" + return []byte{} } // Draw renders the TextEdit component. func (t *TextEdit) Draw(s tcell.Screen) { columnWidth := t.getColumnWidth() - bufferLen := len(t.buffer) + bufferLines := t.Buffer.Lines() textEditStyle := t.Theme.GetOrDefault("TextEdit") selectedStyle := t.Theme.GetOrDefault("TextEditSelected") @@ -428,17 +330,15 @@ func (t *TextEdit) Draw(s tcell.Screen) { lineNumStr := "" - if line < bufferLen { // Only index buffer if we are within it... + if line < bufferLines { // Only index buffer if we are within it... lineNumStr = strconv.Itoa(line + 1) // Line number as a string - var lineStr string // Line to be drawn + lineStr := string(t.Buffer.Line(line)) // Line to be drawn if t.UseHardTabs { - lineStr = strings.ReplaceAll(t.buffer[line], "\t", tabStr) - } else { - lineStr = t.buffer[line] + lineStr = strings.ReplaceAll(lineStr, "\t", tabStr) } - lineRunes := []rune(lineStr) + lineRunes := []rune(lineStr) // TODO: something more efficient here if len(lineRunes) >= t.scrollx { // If some of the line is visible at our horizontal scroll... lineRunes = lineRunes[t.scrollx:] // Trim left side of string we cannot see @@ -454,10 +354,10 @@ func (t *TextEdit) Draw(s tcell.Screen) { tabCount := t.getTabCountInLineAtCol(line, t.selection.StartCol) selStartIdx = t.selection.StartCol + tabCount*(t.TabSize-1) - t.scrollx } - selEndIdx := len(lineRunes) - t.scrollx // used inclusively + selEndIdx := len(lineRunes) - t.scrollx - 1 // used inclusively if line == t.selection.EndLine { // If the selection ends somewhere in the line... tabCount := t.getTabCountInLineAtCol(line, t.selection.EndCol) - selEndIdx = t.selection.EndCol + tabCount*(t.TabSize-1) - t.scrollx + selEndIdx = t.selection.EndCol - 1 + tabCount*(t.TabSize-1) - t.scrollx } // NOTE: a special draw function just for selections. Should combine this with ordinary draw @@ -620,7 +520,7 @@ func (t *TextEdit) HandleEvent(event tcell.Event) bool { t.SetLineCol(t.cury, 0) t.prevCurCol = t.curx case tcell.KeyEnd: - t.SetLineCol(t.cury, len(t.buffer[t.cury])) + t.SetLineCol(t.cury, math.MaxInt32) // Max column t.prevCurCol = t.curx case tcell.KeyPgUp: t.SetLineCol(t.scrolly-t.height, t.curx) // Go a page up @@ -641,7 +541,7 @@ func (t *TextEdit) HandleEvent(event tcell.Event) bool { case tcell.KeyTab: t.Insert("\t") // (can translate to four spaces) case tcell.KeyEnter: - t.insertNewLine() + t.Insert("\n") // Inserting case tcell.KeyRune: