Changed project layout and updated readme

This commit is contained in:
Luke Wilson
2021-04-15 11:59:25 -05:00
parent 27b2f564eb
commit fe6970508d
25 changed files with 35 additions and 526 deletions

90
pkg/buffer/buffer.go Executable file
View File

@@ -0,0 +1,90 @@
package buffer
import (
"io"
)
// A Buffer is wrapper around any buffer data structure like ropes or a gap buffer
// that can be used for text editors. One way this interface helps is by making
// all API function parameters line and column indexes, so it is simple and easy
// to index and use like a text editor. All lines and columns start at zero, and
// all "end" ranges are inclusive.
//
// Any bounds out of range are panics! If you are unsure your position or range
// may be out of bounds, use ClampLineCol() or compare with Lines() or ColsInLine().
type Buffer interface {
// Line returns a slice of the data at the given line, including the ending line-
// delimiter. line starts from zero. Data returned may or may not be a copy: do not
// write to it.
Line(line int) []byte
// Returns a slice of the buffer from startLine, startCol, to endLine, endCol,
// inclusive bounds. The returned value may or may not be a copy of the data,
// so do not write to it.
Slice(startLine, startCol, endLine, endCol int) []byte
// Bytes returns all of the bytes in the buffer. This function is very likely
// to copy all of the data in the buffer. Use sparingly. Try using other methods,
// where possible.
Bytes() []byte
// Insert copies a byte slice (inserting it) into the position at line, col.
Insert(line, col int, value []byte)
// Remove deletes any characters between startLine, startCol, and endLine,
// 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
// Lines returns the number of lines in the buffer. If the buffer is empty,
// 1 is returned, because there is always at least one line. This function
// basically counts the number of newline ('\n') characters in a buffer.
Lines() int
// 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, 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)
// LineColToPos returns the index of the byte at line, col. If line is less than
// zero, or more than the number of available lines, the function will panic. If
// col is less than zero, the function will panic. If col is greater than the
// length of the line, the position of the last byte of the line is returned,
// instead.
LineColToPos(line, col int) int
// PosToLineCol converts a byte offset (position) of the buffer's bytes, into
// a line and column. Unless you are working with the Bytes() function, this
// is unlikely to be useful to you. Position will be clamped.
PosToLineCol(pos int) (int, int)
WriteTo(w io.Writer) (int64, error)
// RegisterCursor adds the Cursor to a slice which the Buffer uses to update
// each Cursor based on changes that occur in the Buffer. Various functions are
// called on the Cursor depending upon where the edits occurred and how it should
// modify the Cursor's position. Unregister a Cursor before deleting it from
// memory, or forgetting it, with UnregisterPosition.
RegisterCursor(cursor *Cursor)
// UnregisterCursor will remove the cursor from the list of watched Cursors.
// It is mandatory that a Cursor be unregistered before being freed from memory,
// or otherwise being forgotten.
UnregisterCursor(cursor *Cursor)
}

103
pkg/buffer/cursor.go Executable file
View File

@@ -0,0 +1,103 @@
package buffer
import "math"
// So why is the code for moving the cursor in the buffer package, and not in the
// TextEdit component? Well, it used to be, but it sucked that way. The cursor
// needs to have a reference to the buffer to know where lines end and how it can
// move. The buffer is the city, and the Cursor is the car.
type position struct {
line int
col int
}
// A Selection represents a region of the buffer to be selected for text editing
// purposes. It is asserted that the start position is less than the end position.
// The start and end are inclusive. If the EndCol of a Region is one more than the
// last column of a line, then it points to the line delimiter at the end of that
// line. It is understood that as a Region spans multiple lines, those connecting
// line-delimiters are included in the selection, as well.
type Region struct {
Start Cursor
End Cursor
}
func NewRegion(in *Buffer) Region {
return Region{
NewCursor(in),
NewCursor(in),
}
}
// A Cursor's functions emulate common cursor actions. To have a Cursor be
// automatically updated when the buffer has text prepended or appended -- one
// should register the Cursor with the Buffer's function `RegisterCursor()`
// which makes the Cursor "anchored" to the Buffer.
type Cursor struct {
buffer *Buffer
prevCol int
position
}
func NewCursor(in *Buffer) Cursor {
return Cursor{
buffer: in,
}
}
func (c Cursor) Left() Cursor {
if c.col == 0 && c.line != 0 { // If we are at the beginning of the current line...
// Go to the end of the above line
c.line--
c.col = (*c.buffer).RunesInLine(c.line)
} else {
c.col = Max(c.col-1, 0)
}
return c
}
func (c Cursor) Right() Cursor {
// If we are at the end of the current line,
// and not at the last line...
if c.col >= (*c.buffer).RunesInLine(c.line) && c.line < (*c.buffer).Lines()-1 {
c.line, c.col = (*c.buffer).ClampLineCol(c.line+1, 0) // Go to beginning of line below
} else {
c.line, c.col = (*c.buffer).ClampLineCol(c.line, c.col+1)
}
return c
}
func (c Cursor) Up() Cursor {
if c.line == 0 { // If the cursor is at the first line...
c.line, c.col = 0, 0 // Go to beginning
} else {
c.line, c.col = (*c.buffer).ClampLineCol(c.line-1, c.col)
}
return c
}
func (c Cursor) Down() Cursor {
if c.line == (*c.buffer).Lines()-1 { // If the cursor is at the last line...
c.line, c.col = (*c.buffer).ClampLineCol(c.line, math.MaxInt32) // Go to end of current line
} else {
c.line, c.col = (*c.buffer).ClampLineCol(c.line+1, c.col)
}
return c
}
func (c Cursor) GetLineCol() (line, col int) {
return c.line, c.col
}
// SetLineCol sets the line and col of the Cursor to those provided. `line` is
// clamped within the range (0, lines in buffer). `col` is then clamped within
// the range (0, line length in runes).
func (c Cursor) SetLineCol(line, col int) Cursor {
c.line, c.col = (*c.buffer).ClampLineCol(line, col)
return c
}
func (c Cursor) Eq(other Cursor) bool {
return c.buffer == other.buffer && c.line == other.line && c.col == other.col
}

193
pkg/buffer/highlighter.go Executable file
View File

@@ -0,0 +1,193 @@
package buffer
import (
"regexp"
"sort"
"github.com/gdamore/tcell/v2"
)
type Colorscheme map[Syntax]tcell.Style
// Gets the tcell.Style from the Colorscheme map for the given Syntax.
// If the Syntax cannot be found in the map, either the `Default` Syntax
// is used, or `tcell.DefaultStyle` is returned if the Default is not assigned.
func (c *Colorscheme) GetStyle(s Syntax) tcell.Style {
if c != nil {
if val, ok := (*c)[s]; ok {
return val // Try to return the requested value
} else if s != Default {
if val, ok := (*c)[Default]; ok {
return val // Use default colorscheme value, instead
}
}
}
return tcell.StyleDefault // No value for Default; use default style.
}
type RegexpRegion struct {
Start *regexp.Regexp
End *regexp.Regexp // Should be "$" by default
Skip *regexp.Regexp // Optional
Error *regexp.Regexp // Optional
Specials []*regexp.Regexp // Optional (nil or zero len)
}
type Match struct {
Col int
EndLine int // Inclusive
EndCol int // Inclusive
Syntax Syntax
}
// ByCol implements sort.Interface for []Match based on the Col field.
type ByCol []Match
func (c ByCol) Len() int { return len(c) }
func (c ByCol) Swap(i, j int) { c[i], c[j] = c[j], c[i] }
func (c ByCol) Less(i, j int) bool { return c[i].Col < c[j].Col }
// A Highlighter can answer how to color any part of a provided Buffer. It does so
// by applying regular expressions over a region of the buffer.
type Highlighter struct {
Buffer Buffer
Language *Language
Colorscheme *Colorscheme
lineMatches [][]Match
}
func NewHighlighter(buffer Buffer, lang *Language, colorscheme *Colorscheme) *Highlighter {
return &Highlighter{
buffer,
lang,
colorscheme,
make([][]Match, buffer.Lines()),
}
}
func (h *Highlighter) expandToBufferLines() {
if lines := h.Buffer.Lines(); len(h.lineMatches) < lines {
h.lineMatches = append(h.lineMatches, make([][]Match, lines-len(h.lineMatches))...) // Extend from Slice Tricks
}
}
// UpdateLines forces the highlighting matches for lines between startLine to
// endLine, inclusively, to be updated. It is more efficient to mark lines as
// invalidated when changes occur and call UpdateInvalidatedLines(...).
func (h *Highlighter) UpdateLines(startLine, endLine int) {
h.expandToBufferLines()
h.updateLines(startLine, endLine)
}
func (h *Highlighter) updateLines(startLine, endLine int) {
for i := startLine; i <= endLine && i < len(h.lineMatches); i++ {
if h.lineMatches[i] != nil {
h.lineMatches[i] = h.lineMatches[i][:0] // Shrink slice to zero (hopefully save allocs)
}
}
// If the rule k does not have an End, then it can be optimized that we search from the start
// of view until the end of view. For any k that has an End, we search for ends from start
// of view, backtracking when one is found, to fulfill a multiline highlight.
endLine, endCol := h.Buffer.ClampLineCol(endLine, (h.Buffer).RunesInLineWithDelim(endLine)-1)
startPos := h.Buffer.LineColToPos(startLine, 0)
bytes := h.Buffer.Slice(startLine, 0, endLine, endCol)
for k, v := range h.Language.Rules {
var indexes [][]int // [][2]int
if k.End != nil && k.End.String() != "$" { // If this range might be a multiline range...
endIndexes := k.End.FindAllIndex(bytes, -1) // Attempt to find every ending match
startIndexes := k.Start.FindAllIndex(bytes, -1) // Attempt to find every starting match
// ...
_ = endIndexes
_ = startIndexes
} else { // A standard single-line match
indexes = k.Start.FindAllIndex(bytes, -1) // Attempt to find the start match
}
for i := range indexes {
startLine, startCol := h.Buffer.PosToLineCol(indexes[i][0] + startPos)
endLine, endCol := h.Buffer.PosToLineCol(indexes[i][1] - 1 + startPos)
match := Match{startCol, endLine, endCol, v}
h.lineMatches[startLine] = append(h.lineMatches[startLine], match) // Unsorted
}
}
h.validateLines(startLine, endLine) // Marks any "unvalidated" or nil lines as valued
}
// UpdateInvalidatedLines only updates the highlighting for lines that are invalidated
// between lines startLine and endLine, inclusively.
func (h *Highlighter) UpdateInvalidatedLines(startLine, endLine int) {
h.expandToBufferLines()
// Move startLine to first line with invalidated changes
for startLine <= endLine && startLine < len(h.lineMatches)-1 {
if h.lineMatches[startLine] == nil {
break
}
startLine++
}
// Keep endLine clamped
if endLine >= len(h.lineMatches) {
endLine = len(h.lineMatches) - 1
}
// Move endLine back to first line at or before endLine with invalidated changes
for endLine >= startLine && endLine > 0 {
if h.lineMatches[endLine] == nil {
break
}
endLine--
}
if startLine > endLine {
return // Do nothing; no invalidated lines
}
h.updateLines(startLine, endLine)
}
func (h *Highlighter) HasInvalidatedLines(startLine, endLine int) bool {
h.expandToBufferLines()
for i := startLine; i <= endLine && i < len(h.lineMatches); i++ {
if h.lineMatches[i] == nil {
return true
}
}
return false
}
func (h *Highlighter) validateLines(startLine, endLine int) {
for i := startLine; i <= endLine && i < len(h.lineMatches); i++ {
if h.lineMatches[i] == nil {
h.lineMatches[i] = make([]Match, 0)
}
}
}
func (h *Highlighter) InvalidateLines(startLine, endLine int) {
h.expandToBufferLines()
for i := startLine; i <= endLine && i < len(h.lineMatches); i++ {
h.lineMatches[i] = nil
}
}
func (h *Highlighter) GetLineMatches(line int) []Match {
if line < 0 || line >= len(h.lineMatches) {
return nil
}
data := h.lineMatches[line]
sort.Sort(ByCol(data))
return data
}
func (h *Highlighter) GetStyle(match Match) tcell.Style {
return h.Colorscheme.GetStyle(match.Syntax)
}

23
pkg/buffer/language.go Executable file
View File

@@ -0,0 +1,23 @@
package buffer
type Syntax uint8
const (
Default Syntax = iota
Column // Not necessarily a Syntax; useful for Colorscheming editor column
Keyword
String
Special
Type
Number
Builtin
Comment
DocComment
)
type Language struct {
Name string
Filetypes []string // .go, .c, etc.
Rules map[*RegexpRegion]Syntax
// TODO: add other language details
}

358
pkg/buffer/rope.go Executable file
View File

@@ -0,0 +1,358 @@
package buffer
import (
"io"
"unicode/utf8"
ropes "github.com/zyedidia/rope"
)
type RopeBuffer struct {
rope *ropes.Node
anchors []*Cursor
}
func NewRopeBuffer(contents []byte) *RopeBuffer {
return &RopeBuffer{
ropes.New(contents),
nil,
}
}
// LineColToPos returns the index of the byte at line, col. If line is less than
// zero, or more than the number of available lines, the function will panic. If
// col is less than zero, the function will panic. If col is greater than the
// length of the line, the position of the last byte of the line is returned,
// instead.
func (b *RopeBuffer) LineColToPos(line, col int) int {
pos := b.getLineStartPos(line)
// Have to do this algorithm for safety. If this function was declared to panic
// or index out of bounds memory, if col > the given line length, it would be
// more efficient and simpler. But unfortunately, I believe it is necessary.
if col > 0 {
_, r := b.rope.SplitAt(pos)
l, _ := r.SplitAt(b.rope.Len() - pos)
l.EachLeaf(func(n *ropes.Node) bool {
data := n.Value() // Reference; not a copy.
for _, r := range string(data) {
if col == 0 || r == '\n' {
return true // Found the position of the column
}
pos++
col--
}
return false // Have not gotten to the appropriate position, yet
})
}
return pos
}
// Line returns a slice of the data at the given line, including the ending line-
// delimiter. line starts from zero. Data returned may or may not be a copy: do not
// write it.
func (b *RopeBuffer) Line(line int) []byte {
pos := b.getLineStartPos(line)
bytes := 0
_, r := b.rope.SplitAt(pos)
l, _ := r.SplitAt(b.rope.Len() - pos)
var isCRLF bool // true if the last byte was '\r'
l.EachLeaf(func(n *ropes.Node) bool {
data := n.Value() // Reference; not a copy.
for i, r := range string(data) {
if r == '\r' {
isCRLF = true
} else if r == '\n' {
bytes += i // Add bytes before i
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
}
}
bytes += len(data)
return false // Have not read the whole line, yet
})
return b.rope.Slice(pos, pos+bytes) // NOTE: may be faster to do it ourselves
}
// Returns a slice of the buffer from startLine, startCol, to endLine, endCol,
// inclusive bounds. The returned value may or may not be a copy of the data,
// so do not write to it.
func (b *RopeBuffer) Slice(startLine, startCol, endLine, endCol int) []byte {
endPos := b.LineColToPos(endLine, endCol)
if length := b.rope.Len(); endPos >= length {
endPos = length - 1
}
return b.rope.Slice(b.LineColToPos(startLine, startCol), endPos+1)
}
// Bytes returns all of the bytes in the buffer. This function is very likely
// to copy all of the data in the buffer. Use sparingly. Try using other methods,
// where possible.
func (b *RopeBuffer) Bytes() []byte {
return b.rope.Value()
}
// Insert copies a byte slice (inserting it) into the position at line, col.
func (b *RopeBuffer) Insert(line, col int, value []byte) {
b.rope.Insert(b.LineColToPos(line, col), value)
b.shiftAnchors(line, col, utf8.RuneCount(value))
}
// Remove deletes any characters between startLine, startCol, and endLine,
// endCol, inclusive bounds.
func (b *RopeBuffer) Remove(startLine, startCol, endLine, endCol int) {
start := b.LineColToPos(startLine, startCol)
end := b.LineColToPos(endLine, endCol) + 1
if len := b.rope.Len(); end >= len {
end = len
if start > end {
return
}
}
b.rope.Remove(start, end)
// Shift anchors within the range
b.shiftAnchorsRemovedRange(start, startLine, startCol, endLine, endCol)
// Shift anchors after the range
b.shiftAnchors(endLine, endCol+1, start-end-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.LineColToPos(startLine, startCol)
endPos := b.LineColToPos(endLine, endCol)
return b.rope.Count(startPos, endPos, sequence)
}
// Len returns the number of bytes in the buffer.
func (b *RopeBuffer) Len() int {
return b.rope.Len()
}
// Lines returns the number of lines in the buffer. If the buffer is empty,
// 1 is returned, because there is always at least one line. This function
// basically counts the number of newline ('\n') characters in a buffer.
func (b *RopeBuffer) Lines() int {
rope := b.rope
return rope.Count(0, rope.Len(), []byte{'\n'}) + 1
}
// 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
// than or equal to the number of lines in the buffer, a panic is issued.
func (b *RopeBuffer) getLineStartPos(line int) int {
var pos int
if line > 0 {
b.rope.IndexAllFunc(0, b.rope.Len(), []byte{'\n'}, func(idx int) bool {
line--
pos = idx + 1 // idx+1 = start of line after delimiter
return line <= 0 // If pos is now the start of the line we're searching for
})
}
if line > 0 { // If there aren't enough lines to reach line...
panic("getLineStartPos: not enough lines in buffer to reach position")
}
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)
ropeLen := b.rope.Len()
if linePos >= ropeLen {
return 0
}
var count int
_, r := b.rope.SplitAt(linePos)
l, _ := r.SplitAt(ropeLen - linePos)
l.EachLeaf(func(n *ropes.Node) bool {
data := n.Value() // Reference; not a copy.
for _, r := range string(data) {
count++ // Before: we count the line delimiter
if r == '\n' {
return true // Read (past-tense) the whole line
}
}
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. Excludes line delimiters.
func (b *RopeBuffer) RunesInLine(line int) int {
linePos := b.getLineStartPos(line)
ropeLen := b.rope.Len()
if linePos >= ropeLen {
return 0
}
var count int
_, r := b.rope.SplitAt(linePos)
l, _ := r.SplitAt(ropeLen - linePos)
var isCRLF bool
l.EachLeaf(func(n *ropes.Node) bool {
data := n.Value() // Reference; not a copy.
for _, r := range string(data) {
if r == '\r' {
isCRLF = true
} else if r == '\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++
}
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 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
} else if lines := b.Lines() - 1; line > lines {
line = lines
}
if col < 0 {
col = 0
} else if runes := b.RunesInLine(line); col > runes {
col = runes
}
return line, col
}
// PosToLineCol converts a byte offset (position) of the buffer's bytes, into
// a line and column. Unless you are working with the Bytes() function, this
// is unlikely to be useful to you. Position will be clamped.
func (b *RopeBuffer) PosToLineCol(pos int) (int, int) {
var line, col int
var wasAtNewline bool
if pos <= 0 {
return line, col
}
b.rope.EachLeaf(func(n *ropes.Node) bool {
data := n.Value()
var i int
for i < len(data) {
if wasAtNewline { // Start of line
if data[i] != '\n' { // If the start of this line does not happen to be a delim...
wasAtNewline = false // Say we weren't previously at a delimiter
}
line, col = line+1, 0
} else if data[i] == '\n' { // End of line
wasAtNewline = true
col++
} else {
col++
}
_, size := utf8.DecodeRune(data[i:])
i += size
pos -= size
if pos < 0 {
return true
}
}
return false
})
return line, col
}
func (b *RopeBuffer) WriteTo(w io.Writer) (int64, error) {
return b.rope.WriteTo(w)
}
// Currently meant for the Remove function: imagine if the removed region passes through a Cursor position.
// We want to shift the cursor to the start of the region, based upon where the cursor position is.
func (b *RopeBuffer) shiftAnchorsRemovedRange(startPos, startLine, startCol, endLine, endCol int) {
for _, v := range b.anchors {
cursorLine, cursorCol := v.GetLineCol()
if cursorLine >= startLine && cursorLine <= endLine {
// If the anchor is not within the start or end columns
if (cursorLine == startLine && cursorCol < startCol) || (cursorLine == endLine && cursorCol > endCol) {
continue
}
cursorPos := b.LineColToPos(cursorLine, cursorCol)
v.line, v.col = b.PosToLineCol(cursorPos + (startPos - cursorPos))
}
}
}
func (b *RopeBuffer) shiftAnchors(insertLine, insertCol, runeCount int) {
for _, v := range b.anchors {
cursorLine, cursorCol := v.GetLineCol()
if insertLine < cursorLine || (insertLine == cursorLine && insertCol <= cursorCol) {
v.line, v.col = b.PosToLineCol(b.LineColToPos(cursorLine, cursorCol) + runeCount)
}
}
}
// RegisterCursor adds the Cursor to a slice which the Buffer uses to update
// each Cursor based on changes that occur in the Buffer. Various functions are
// called on the Cursor depending upon where the edits occurred and how it should
// modify the Cursor's position. Unregister a Cursor before deleting it from
// memory, or forgetting it, with UnregisterPosition.
func (b *RopeBuffer) RegisterCursor(cursor *Cursor) {
if cursor == nil {
return
}
b.anchors = append(b.anchors, cursor)
}
// UnregisterCursor will remove the cursor from the list of watched Cursors.
// It is mandatory that a Cursor be unregistered before being freed from memory,
// or otherwise being forgotten.
func (b *RopeBuffer) UnregisterCursor(cursor *Cursor) {
for i, v := range b.anchors {
if cursor == v {
// Delete item at i without preserving order
b.anchors[i] = b.anchors[len(b.anchors)-1]
b.anchors[len(b.anchors)-1] = nil
b.anchors = b.anchors[:len(b.anchors)-1]
}
}
}

148
pkg/buffer/rope_test.go Normal file
View File

@@ -0,0 +1,148 @@
package buffer
import "testing"
func TestRopePosToLineCol(t *testing.T) {
var buf Buffer = NewRopeBuffer([]byte("line0\nline1\n\nline3\n"))
//line0
//line1
//
//line3
//
startLine, startCol := buf.PosToLineCol(0)
if startLine != 0 {
t.Errorf("Expected startLine == 0, got %v", startLine)
}
if startCol != 0 {
t.Errorf("Expected startCol == 0, got %v", startCol)
}
endPos := buf.Len() - 1
endLine, endCol := buf.PosToLineCol(endPos)
t.Logf("endPos = %v", endPos)
if endLine != 3 {
t.Errorf("Expected endLine == 3, got %v", endLine)
}
if endCol != 5 {
t.Errorf("Expected endCol == 5, got %v", endCol)
}
line1Pos := 11 // Byte index of the delim separating line1 and line 2
line1Line, line1Col := buf.PosToLineCol(line1Pos)
if line1Line != 1 {
t.Errorf("Expected line1Line == 1, got %v", line1Line)
}
if line1Col != 5 {
t.Errorf("Expected line1Col == 5, got %v", line1Col)
}
}
func TestRopeInserting(t *testing.T) {
var buf Buffer = NewRopeBuffer([]byte("some"))
buf.Insert(0, 4, []byte(" text\n")) // Insert " text" after "some"
buf.Insert(0, 0, []byte("with\n\t"))
//with
// some text
//
buf.Remove(0, 4, 1, 5) // Delete from line 0, col 4, to line 1, col 6 "\n\tsome "
if str := string(buf.Bytes()); str != "withtext\n" {
t.Errorf("string does not match \"withtext\", got %#v", str)
}
}
func TestRopeBounds(t *testing.T) {
var buf Buffer = NewRopeBuffer([]byte("this\nis (は)\n\tsome\ntext\n"))
//this
//is (は)
// some
//text
//
if buf.Lines() != 5 {
t.Errorf("Expected buf.Lines() == 5")
}
if len := buf.RunesInLine(1); len != 6 { // "is" in English and in japanese
t.Errorf("Expected 6 runes in line 2, found %v", len)
}
if len := buf.RunesInLineWithDelim(4); len != 0 {
t.Errorf("Expected 0 runes in line 5, found %v", len)
}
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)
}
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)
}
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)
}
if line := string(buf.Line(2)); line != "\tsome\n" {
t.Errorf("Expected line 3 to equal \"\\tsome\", got %#v", line)
}
if line := string(buf.Line(4)); line != "" {
t.Errorf("Got %#v", line)
}
}
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)
}
tabs := buf.Count(0, 0, 0, 0, []byte{'\t'})
if tabs != 0 {
t.Errorf("Expected no tabs at column zero, got %v", tabs)
}
}
func TestRopeSlice(t *testing.T) {
var buf Buffer = NewRopeBuffer([]byte("abc\ndef\n"))
wholeSlice := buf.Slice(0, 0, 2, 0) // Position points to after the newline char
if string(wholeSlice) != "abc\ndef\n" {
t.Errorf("Whole slice was not equal, got \"%s\"", wholeSlice)
}
secondLine := buf.Slice(1, 0, 1, 3)
if string(secondLine) != "def\n" {
t.Errorf("Second line and slice were not equal, got \"%s\"", secondLine)
}
}
func TestRopeAnchors(t *testing.T) {
var buf Buffer = NewRopeBuffer([]byte("abc\ndef\nghi"))
myCursor := Cursor{buffer: &buf, position: position{1, 1}} // Points to 'e' on second line
buf.RegisterCursor(&myCursor) // Now the buffer will move the cursor based on changes to contents
buf.Remove(1, 0, 1, 3) // Remove whole second line ('d' to '\n')
if line, col := myCursor.GetLineCol(); line != 1 && col != 0 {
t.Errorf("Expected cursor line,col: %d,%d ; got %d,%d", 1, 0, line, col)
}
buf.Insert(0, 0, []byte("def\n")) // "def\nabc\nghi"
if line, col := myCursor.GetLineCol(); line != 2 && col != 0 {
t.Errorf("Expected cursor line,col: %d,%d ; got %d,%d", 2, 0, line, col)
}
buf.UnregisterCursor(&myCursor)
}

23
pkg/buffer/util.go Normal file
View File

@@ -0,0 +1,23 @@
package buffer
// Max returns the larger integer.
func Max(a, b int) int {
if a > b {
return a
}
return b
}
// Min returns the smaller integer.
func Min(a, b int) int {
if a < b {
return a
}
return b
}
// Clamp keeps `v` within `a` and `b` numerically. `a` must be smaller than `b`.
// Returns clamped `v`.
func Clamp(v, a, b int) int {
return Max(a, Min(v, b))
}

58
pkg/ui/button.go Normal file
View File

@@ -0,0 +1,58 @@
package ui
import (
"fmt"
"github.com/gdamore/tcell/v2"
)
type Button struct {
Text string
Callback func()
baseComponent
}
func NewButton(text string, theme *Theme, callback func()) *Button {
return &Button{
text,
callback,
baseComponent{theme: theme},
}
}
func (b *Button) Draw(s tcell.Screen) {
var str string
if b.focused {
str = fmt.Sprintf("🭬 %s 🭮", b.Text)
} else {
str = fmt.Sprintf(" %s ", b.Text)
}
DrawStr(s, b.x, b.y, str, b.theme.GetOrDefault("Button"))
}
func (b *Button) GetMinSize() (int, int) {
return len(b.Text) + 4, 1
}
func (b *Button) GetSize() (int, int) {
return b.GetMinSize()
}
func (b *Button) SetSize(width, height int) {}
func (b *Button) HandleEvent(event tcell.Event) bool {
if b.focused {
switch ev := event.(type) {
case *tcell.EventKey:
if ev.Key() == tcell.KeyEnter {
if b.Callback != nil {
b.Callback()
}
}
default:
return false
}
return true
}
return false
}

82
pkg/ui/component.go Normal file
View File

@@ -0,0 +1,82 @@
package ui
import (
"github.com/gdamore/tcell/v2"
)
// A Component refers generally to the behavior of a UI "component". Components
// include buttons, input fields, and labels. It is expected that after constructing
// a component, to call the SetPos() function, and possibly SetSize() as well.
//
// Many components implement their own `New...()` function. In those constructor
// functions, it is good practice for that component to set its size to be its
// minimum size.
type Component interface {
// A component knows its position and size, which is used to draw itself in
// its bounding rectangle.
Draw(tcell.Screen)
// Components can be focused, which may affect how it handles events or draws.
// For example, when a button is focused, the Return key may be pressed to
// activate the button.
SetFocused(bool)
// Applies the theme to the component and all of its children.
SetTheme(*Theme)
// Get position of the Component.
GetPos() (x, y int)
// Set position of the Component.
SetPos(x, y int)
// Returns the smallest size the Component can be.
GetMinSize() (w, h int)
// Get size of the Component.
GetSize() (w, h int)
// Set size of the component. If size is smaller than minimum, minimum is
// used, instead.
SetSize(w, h int)
// HandleEvent tells the Component to handle the provided event. The Component
// should only handle events if it is focused. An event can optionally be
// handled. If an event is handled, the function should return true. If the
// event went unhandled, the function should return false.
HandleEvent(tcell.Event) bool
}
// baseComponent can be embedded in a Component's struct to hide a few of the
// boilerplate fields and functions. The baseComponent defines defaults for
// ...Pos(), ...Size(), SetFocused(), and SetTheme() functions that can be
// overriden.
type baseComponent struct {
focused bool
x, y int
width, height int
theme *Theme
}
func (c *baseComponent) SetFocused(v bool) {
c.focused = v
}
func (c *baseComponent) SetTheme(theme *Theme) {
c.theme = theme
}
func (c *baseComponent) GetPos() (int, int) {
return c.x, c.y
}
func (c *baseComponent) SetPos(x, y int) {
c.x, c.y = x, y
}
func (c *baseComponent) GetMinSize() (int, int) {
return 0, 0
}
func (c *baseComponent) GetSize() (int, int) {
return c.width, c.height
}
func (c *baseComponent) SetSize(width, height int) {
c.width, c.height = width, height
}

92
pkg/ui/drawfunctions.go Normal file
View File

@@ -0,0 +1,92 @@
package ui
import (
"github.com/gdamore/tcell/v2"
"github.com/mattn/go-runewidth"
)
// DrawRect renders a filled box at `x` and `y`, of size `width` and `height`.
// Will not call `Show()`.
func DrawRect(s tcell.Screen, x, y, width, height int, char rune, style tcell.Style) {
for col := x; col < x+width; col++ {
for row := y; row < y+height; row++ {
s.SetContent(col, row, char, nil, style)
}
}
}
// DrawStr will render each character of a string at `x` and `y`. Returned is
// the number of columns that were drawn to the screen.
func DrawStr(s tcell.Screen, x, y int, str string, style tcell.Style) int {
var col int
for _, r := range str {
if r == '\n' {
col = 0
y++
} else {
s.SetContent(x+col, y, r, nil, style)
}
col += runewidth.RuneWidth(r)
}
return col
}
// DrawQuickCharStr renders a string very similar to how DrawStr works, but stylizes the
// quick char (the rune at `quickCharIdx`) with an underline. Returned is the number of
// columns that were drawn to the screen.
func DrawQuickCharStr(s tcell.Screen, x, y int, str string, quickCharIdx int, style tcell.Style) int {
var col int
var runeIdx int
for _, r := range str {
sty := style
if runeIdx == quickCharIdx {
sty = style.Underline(true)
}
s.SetContent(x+col, y, r, nil, sty)
runeIdx++
col += runewidth.RuneWidth(r)
}
return col
}
// DrawRectOutline draws only the outline of a rectangle, using `ul`, `ur`, `bl`, and `br`
// for the corner runes, and `hor` and `vert` for the horizontal and vertical runes, respectively.
func DrawRectOutline(s tcell.Screen, x, y, _width, _height int, ul, ur, bl, br, hor, vert rune, style tcell.Style) {
width := x + _width - 1 // Length across
height := y + _height - 1 // Length top-to-bottom
// Horizontals and verticals
for col := x + 1; col < width; col++ {
s.SetContent(col, y, hor, nil, style) // Top line
s.SetContent(col, height, hor, nil, style) // Bottom line
}
for row := y + 1; row < height; row++ {
s.SetContent(x, row, vert, nil, style) // Left line
s.SetContent(width, row, vert, nil, style) // Right line
}
// Corners
s.SetContent(x, y, ul, nil, style)
s.SetContent(width, y, ur, nil, style)
s.SetContent(x, height, bl, nil, style)
s.SetContent(width, height, br, nil, style)
}
// DrawRectOutlineDefault calls DrawRectOutline with the default edge runes.
func DrawRectOutlineDefault(s tcell.Screen, x, y, width, height int, style tcell.Style) {
DrawRectOutline(s, x, y, width, height, '┌', '┐', '└', '┘', '─', '│', style)
}
// DrawWindow draws a window-like object at x and y as the top-left corner. This window
// has an optional title. The Theme values "WindowHeader" and "Window" are used.
func DrawWindow(s tcell.Screen, x, y, width, height int, title string, theme *Theme) {
headerStyle := theme.GetOrDefault("WindowHeader")
DrawRect(s, x, y, width, 1, ' ', headerStyle) // Draw header background
DrawStr(s, x+width/2-len(title)/2, y, title, headerStyle) // Draw header title
DrawRect(s, x, y+1, width, height-1, ' ', theme.GetOrDefault("Window")) // Draw body
}
// TODO: add DrawShadow(x, y, width, height int)

View File

@@ -0,0 +1,129 @@
package ui
import (
"strings"
"github.com/gdamore/tcell/v2"
)
// A FileSelectorDialog is a WindowContainer with an input and buttons for selecting files.
// It can be used to open zero or more existing files, or select one non-existant file (for saving).
type FileSelectorDialog struct {
Title string
MustExist bool // Whether the dialog should have a user select an existing file.
FilesChosenCallback func([]string) // Returns slice of filenames selected. nil if user canceled.
tabOrder []Component
tabOrderIdx int
inputField *InputField
confirmButton *Button
cancelButton *Button
baseComponent
}
func NewFileSelectorDialog(screen *tcell.Screen, title string, mustExist bool, theme *Theme, filesChosenCallback func([]string), cancelCallback func()) *FileSelectorDialog {
dialog := &FileSelectorDialog{
Title: title,
MustExist: mustExist,
FilesChosenCallback: filesChosenCallback,
baseComponent: baseComponent{theme: theme},
}
dialog.inputField = NewInputField(screen, []byte{}, theme.GetOrDefault("Window")) // Use window's theme for InputField
dialog.confirmButton = NewButton("Confirm", theme, dialog.onConfirm)
dialog.cancelButton = NewButton("Cancel", theme, cancelCallback)
dialog.tabOrder = []Component{dialog.inputField, dialog.cancelButton, dialog.confirmButton}
return dialog
}
// onConfirm is a callback called by the confirm button.
func (d *FileSelectorDialog) onConfirm() {
if d.FilesChosenCallback != nil {
files := strings.Split(string(d.inputField.Buffer), ",") // Split input by commas
for i := range files {
files[i] = strings.TrimSpace(files[i]) // Trim all strings in slice
}
d.FilesChosenCallback(files)
}
}
func (d *FileSelectorDialog) SetCancelCallback(callback func()) {
d.cancelButton.Callback = callback
}
func (d *FileSelectorDialog) Draw(s tcell.Screen) {
DrawWindow(s, d.x, d.y, d.width, d.height, d.Title, d.theme)
// Update positions of child components (dependent on size information that may not be available at SetPos() )
btnWidth, _ := d.confirmButton.GetSize()
d.confirmButton.SetPos(d.x+d.width-btnWidth-1, d.y+4) // Place "Ok" button on right, bottom
d.inputField.Draw(s)
d.confirmButton.Draw(s)
d.cancelButton.Draw(s)
}
func (d *FileSelectorDialog) SetFocused(v bool) {
d.focused = v
d.tabOrder[d.tabOrderIdx].SetFocused(v)
}
func (d *FileSelectorDialog) SetTheme(theme *Theme) {
d.theme = theme
d.inputField.SetStyle(theme.GetOrDefault("Window"))
d.confirmButton.SetTheme(theme)
d.cancelButton.SetTheme(theme)
}
func (d *FileSelectorDialog) SetPos(x, y int) {
d.x, d.y = x, y
d.inputField.SetPos(d.x+1, d.y+2) // Center input field
d.cancelButton.SetPos(d.x+1, d.y+4) // Place "Cancel" button on left, bottom
}
func (d *FileSelectorDialog) GetMinSize() (int, int) {
return Max(len(d.Title), 8) + 2, 6
}
func (d *FileSelectorDialog) SetSize(width, height int) {
minX, minY := d.GetMinSize()
d.width, d.height = Max(width, minX), Max(height, minY)
d.inputField.SetSize(d.width-2, 1)
d.cancelButton.SetSize(d.cancelButton.GetMinSize())
d.confirmButton.SetSize(d.confirmButton.GetMinSize())
}
func (d *FileSelectorDialog) HandleEvent(event tcell.Event) bool {
switch ev := event.(type) {
case *tcell.EventKey:
switch ev.Key() {
case tcell.KeyTab:
d.tabOrder[d.tabOrderIdx].SetFocused(false)
d.tabOrderIdx++
if d.tabOrderIdx >= len(d.tabOrder) {
d.tabOrderIdx = 0
}
d.tabOrder[d.tabOrderIdx].SetFocused(true)
return true
case tcell.KeyEsc:
if d.cancelButton.Callback != nil {
d.cancelButton.Callback()
}
return true
case tcell.KeyEnter:
if d.tabOrder[d.tabOrderIdx] == d.inputField {
d.onConfirm()
return true
}
}
}
return d.tabOrder[d.tabOrderIdx].HandleEvent(event)
}

191
pkg/ui/inputfield.go Normal file
View File

@@ -0,0 +1,191 @@
package ui
import (
"unicode/utf8"
"github.com/gdamore/tcell/v2"
)
// An InputField is a single-line input box.
type InputField struct {
Buffer []byte
cursorPos int
scrollPos int
screen *tcell.Screen
style tcell.Style
baseComponent
}
func NewInputField(screen *tcell.Screen, placeholder []byte, style tcell.Style) *InputField {
return &InputField{
Buffer: append(make([]byte, 0, Max(len(placeholder), 32)), placeholder...),
screen: screen,
style: style,
}
}
func (f *InputField) String() string {
return string(f.Buffer)
}
func (f *InputField) GetCursorPos() int {
return f.cursorPos
}
// SetCursorPos sets the cursor position offset. Offset is clamped to possible values.
// The InputField is scrolled to show the new cursor position. The offset is in runes.
func (f *InputField) SetCursorPos(offset int) {
// Clamping
if offset < 0 {
offset = 0
} else if runes := utf8.RuneCount(f.Buffer); offset > runes {
offset = runes
}
// Scrolling
if offset >= f.scrollPos+f.width-2 { // If cursor position is out of view to the right...
f.scrollPos = offset - f.width + 2 // Scroll just enough to view that column
} else if offset < f.scrollPos { // If cursor position is out of view to the left...
f.scrollPos = offset
}
f.cursorPos = offset
if f.focused {
(*f.screen).ShowCursor(f.x+offset-f.scrollPos+1, f.y)
}
}
func (f *InputField) runeIdxToByteIdx(idx int) int {
var i int
for idx > 0 {
_, size := utf8.DecodeRune(f.Buffer[i:])
i += size
idx--
}
return i
}
func (f *InputField) Insert(contents []byte) {
f.Buffer = f.insert(f.Buffer, f.runeIdxToByteIdx(f.cursorPos), contents...)
f.SetCursorPos(f.cursorPos + utf8.RuneCount(contents))
}
// Efficient slice inserting from Slice Tricks.
func (f *InputField) insert(dst []byte, at int, src ...byte) []byte {
if n := len(dst) + len(src); n <= cap(dst) {
dstn := dst[:n]
copy(dstn[at+len(src):], dst[at:])
copy(dstn[at:], src)
return dstn
}
dstn := make([]byte, len(dst)+len(src))
copy(dstn, dst[:at])
copy(dstn[at:], src)
copy(dstn[at+len(src):], dst[at:])
return dstn
}
func (f *InputField) Delete(forward bool) {
if forward {
if f.cursorPos < utf8.RuneCount(f.Buffer) { // If the cursor is not at the end...
f.Buffer = f.delete(f.Buffer, f.runeIdxToByteIdx(f.cursorPos))
}
} else {
if f.cursorPos > 0 { // If the cursor is not at the beginning...
f.SetCursorPos(f.cursorPos - 1)
f.Buffer = f.delete(f.Buffer, f.runeIdxToByteIdx(f.cursorPos))
}
}
}
func (f *InputField) delete(dst []byte, at int) []byte {
copy(dst[at:], dst[at+1:])
dst[len(dst)-1] = 0
dst = dst[:len(dst)-1]
return dst
}
func (f *InputField) Draw(s tcell.Screen) {
s.SetContent(f.x, f.y, '[', nil, f.style)
s.SetContent(f.x+f.width-1, f.y, ']', nil, f.style)
fg, bg, attr := f.style.Decompose()
invertedStyle := tcell.Style{}.Foreground(bg).Background(fg).Attributes(attr)
var byteIdx int
var runeIdx int
// Scrolling
for byteIdx < len(f.Buffer) && runeIdx < f.scrollPos {
_, size := utf8.DecodeRune(f.Buffer[byteIdx:])
byteIdx += size
runeIdx++
}
for i := 0; i < f.width-2; i++ { // For each column between [ and ]
if byteIdx < len(f.Buffer) {
// Draw the rune
r, size := utf8.DecodeRune(f.Buffer[byteIdx:])
s.SetContent(f.x+1+i, f.y, r, nil, invertedStyle)
byteIdx += size
runeIdx++
} else {
// Draw a '.'
s.SetContent(f.x+1+i, f.y, '.', nil, f.style)
}
}
// Update cursor
f.SetCursorPos(f.cursorPos)
}
func (f *InputField) SetFocused(v bool) {
f.focused = v
if v {
f.SetCursorPos(f.cursorPos)
} else {
(*f.screen).HideCursor()
}
}
func (f *InputField) SetStyle(style tcell.Style) {
f.style = style
}
func (f *InputField) HandleEvent(event tcell.Event) bool {
switch ev := event.(type) {
case *tcell.EventKey:
switch ev.Key() {
// Cursor movement
case tcell.KeyLeft:
f.SetCursorPos(f.cursorPos - 1)
case tcell.KeyRight:
f.SetCursorPos(f.cursorPos + 1)
// Deleting
case tcell.KeyBackspace:
fallthrough
case tcell.KeyBackspace2:
f.Delete(false)
case tcell.KeyDelete:
f.Delete(true)
// Inserting
case tcell.KeyRune:
ch := ev.Rune()
if bytesLen := utf8.RuneLen(ch); bytesLen > 0 {
bytes := make([]byte, bytesLen)
utf8.EncodeRune(bytes, ch)
f.Insert(bytes)
}
default:
return false
}
return true
}
return false
}

25
pkg/ui/label.go Normal file
View File

@@ -0,0 +1,25 @@
package ui
// Align defines the text alignment of a label.
type Align uint8
const (
// AlignLeft is the normal text alignment where text is aligned to the left
// of its bounding box.
AlignLeft Align = iota
// AlignRight causes text to be aligned to the right of its bounding box.
AlignRight
// AlignJustify causes text to be left-aligned, but also spaced so that it
// fits the entire box where it is being rendered.
AlignJustify
)
// A Label is a component for rendering text. Text can be rendered easily
// without a Label, but this component forces the text to fit within its
// bounding box and allows for left-align, right-align, and justify.
type Label struct {
Text string
Alignment Align
baseComponent
}

468
pkg/ui/menu.go Normal file
View File

@@ -0,0 +1,468 @@
package ui
import (
"fmt"
"strings"
"github.com/gdamore/tcell/v2"
runewidth "github.com/mattn/go-runewidth"
)
// Item is an interface implemented by ItemEntry and ItemMenu to be listed in Menus.
type Item interface {
GetName() string
// Returns a character/rune index of the name of the item.
GetQuickCharIdx() int
// A Shortcut is a string of the modifiers+key name of the action that must be pressed
// to trigger the shortcut. For example: "Ctrl+Alt+X". The order of the modifiers is
// very important. Letters are case-sensitive. See the KeyEvent.Name() function of tcell
// for information. An empty string implies no shortcut.
GetShortcut() string
}
// An ItemSeparator is like a blank Item that cannot actually be selected. It is useful
// for separating items in a Menu.
type ItemSeparator struct{}
// GetName returns an empty string.
func (i *ItemSeparator) GetName() string {
return ""
}
func (i *ItemSeparator) GetQuickCharIdx() int {
return 0
}
func (i *ItemSeparator) GetShortcut() string {
return ""
}
// ItemEntry is a listing in a Menu with a name and callback.
type ItemEntry struct {
Name string
QuickChar int // Character/rune index of Name
Shortcut string
Callback func()
}
// GetName returns the name of the ItemEntry.
func (i *ItemEntry) GetName() string {
return i.Name
}
func (i *ItemEntry) GetQuickCharIdx() int {
return i.QuickChar
}
func (i *ItemEntry) GetShortcut() string {
return i.Shortcut
}
// GetName returns the name of the Menu.
func (m *Menu) GetName() string {
return m.Name
}
func (m *Menu) GetQuickCharIdx() int {
return m.QuickChar
}
func (m *Menu) GetShortcut() string {
return ""
}
// A MenuBar is a horizontal list of menus.
type MenuBar struct {
menus []*Menu
selected int // Index of selection in MenuBar
menusVisible bool // Whether to draw the selected menu
baseComponent
}
func NewMenuBar(theme *Theme) *MenuBar {
return &MenuBar{
menus: make([]*Menu, 0, 6),
baseComponent: baseComponent{theme: theme},
}
}
func (b *MenuBar) AddMenu(menu *Menu) {
menu.itemSelectedCallback = func() {
b.menusVisible = false
menu.SetFocused(false)
}
b.menus = append(b.menus, menu)
}
// GetMenuXPos returns the X position of the name of Menu at `idx` visually.
func (b *MenuBar) GetMenuXPos(idx int) int {
x := 1
for i := 0; i < idx; i++ {
x += len(b.menus[i].Name) + 2 // two for padding
}
return x
}
func (b *MenuBar) ActivateMenuUnderCursor() {
b.menusVisible = true // Show menus
menu := &b.menus[b.selected]
(*menu).SetPos(b.GetMenuXPos(b.selected), b.y+1)
(*menu).SetFocused(true)
}
func (b *MenuBar) CursorLeft() {
if b.menusVisible {
b.menus[b.selected].SetFocused(false) // Unfocus current menu
}
if b.selected <= 0 {
b.selected = len(b.menus) - 1 // Wrap to end
} else {
b.selected--
}
if b.menusVisible {
// Update position of new menu after changing menu selection
b.menus[b.selected].SetPos(b.GetMenuXPos(b.selected), b.y+1)
b.menus[b.selected].SetFocused(true) // Focus new menu
}
}
func (b *MenuBar) CursorRight() {
if b.menusVisible {
b.menus[b.selected].SetFocused(false)
}
if b.selected >= len(b.menus)-1 {
b.selected = 0 // Wrap to beginning
} else {
b.selected++
}
if b.menusVisible {
// Update position of new menu after changing menu selection
b.menus[b.selected].SetPos(b.GetMenuXPos(b.selected), b.y+1)
b.menus[b.selected].SetFocused(true) // Focus new menu
}
}
// Draw renders the MenuBar and its sub-menus.
func (b *MenuBar) Draw(s tcell.Screen) {
var normalStyle tcell.Style
if b.focused {
normalStyle = b.theme.GetOrDefault("MenuBarFocused")
} else {
normalStyle = b.theme.GetOrDefault("MenuBar")
}
// Draw menus based on whether b.focused and which is selected
DrawRect(s, b.x, b.y, b.width, 1, ' ', normalStyle)
col := b.x + 1
for i, item := range b.menus {
sty := normalStyle
if b.focused && b.selected == i {
fg, bg, attr := normalStyle.Decompose()
sty = tcell.Style{}.Foreground(bg).Background(fg).Attributes(attr)
}
str := fmt.Sprintf(" %s ", item.Name)
cols := DrawQuickCharStr(s, col, b.y, str, item.QuickChar+1, sty)
col += cols
}
if b.menusVisible {
menu := b.menus[b.selected]
menu.Draw(s) // Draw menu when it is expanded / visible
}
}
// SetFocused highlights the MenuBar and focuses any sub-menus.
func (b *MenuBar) SetFocused(v bool) {
b.focused = v
b.menus[b.selected].SetFocused(v)
if !v {
b.selected = 0 // Reset cursor position every time component is unfocused
if b.menusVisible {
b.menusVisible = false
}
}
}
func (b *MenuBar) GetMinSize() (int, int) {
return 0, 1
}
// HandleEvent will propogate events to sub-menus and returns true if
// any of them handled the event.
func (b *MenuBar) HandleEvent(event tcell.Event) bool {
switch ev := event.(type) {
case *tcell.EventKey:
// Shortcuts (Ctrl-s or Ctrl-A, for example)
if ev.Modifiers() != 0 { // If there is a modifier on the key...
// tcell names it "Ctrl+(Key)" so we want to remove the "Ctrl+"
// prefix, and use the remaining part of the string as the shortcut.
keyName := ev.Name()
// Find who the shortcut key belongs to
for i := range b.menus {
handled := b.menus[i].handleShortcut(keyName)
if handled {
return true
}
}
return false // The shortcut key was not handled by any menus
}
switch ev.Key() {
case tcell.KeyEnter:
if !b.menusVisible { // If menus are not visible...
b.ActivateMenuUnderCursor()
} else { // The selected Menu is visible, send the event to it
return b.menus[b.selected].HandleEvent(event)
}
case tcell.KeyLeft:
b.CursorLeft()
case tcell.KeyRight:
b.CursorRight()
case tcell.KeyTab:
if b.menusVisible {
return b.menus[b.selected].HandleEvent(event)
} else {
b.CursorRight()
}
// Quick char
case tcell.KeyRune: // Search for the matching quick char in menu names
if !b.menusVisible { // If the selected Menu is not open/visible
for i, m := range b.menus {
r := QuickCharInString(m.Name, m.QuickChar)
if r != 0 && r == ev.Rune() {
b.selected = i // Select menu at i
b.ActivateMenuUnderCursor() // Show menu
break
}
}
} else {
return b.menus[b.selected].HandleEvent(event) // Have menu handle quick char event
}
default:
if b.menusVisible {
return b.menus[b.selected].HandleEvent(event)
} else {
return false // Nobody to propogate our event to
}
}
return true
}
return false
}
// A Menu contains one or more ItemEntry or ItemMenus.
type Menu struct {
Name string
QuickChar int // Character/rune index of Name
Items []Item
selected int // Index of selected Item
itemSelectedCallback func() // Used internally to hide menus on selection
baseComponent
}
// New creates a new Menu. `items` can be `nil`.
func NewMenu(name string, quickChar int, theme *Theme) *Menu {
return &Menu{
Name: name,
QuickChar: quickChar,
Items: make([]Item, 0, 6),
baseComponent: baseComponent{theme: theme},
}
}
func (m *Menu) AddItem(item Item) {
switch typ := item.(type) {
case *Menu:
typ.itemSelectedCallback = func() {
m.itemSelectedCallback()
}
}
m.Items = append(m.Items, item)
}
func (m *Menu) AddItems(items []Item) {
for _, item := range items {
m.AddItem(item)
}
}
func (m *Menu) ActivateItemUnderCursor() {
switch item := m.Items[m.selected].(type) {
case *ItemEntry:
item.Callback()
m.itemSelectedCallback()
case *Menu:
// TODO: implement sub-menus ...
}
}
func (m *Menu) CursorUp() {
if m.selected <= 0 {
m.selected = len(m.Items) - 1 // Wrap to end
} else {
m.selected--
}
switch m.Items[m.selected].(type) {
case *ItemSeparator:
m.CursorUp() // Recursion; stack overflow if the only item in a Menu is a separator.
default:
}
}
func (m *Menu) CursorDown() {
if m.selected >= len(m.Items)-1 {
m.selected = 0 // Wrap to beginning
} else {
m.selected++
}
switch m.Items[m.selected].(type) {
case *ItemSeparator:
m.CursorDown() // Recursion; stack overflow if the only item in a Menu is a separator.
default:
}
}
// Draw renders the Menu at its position.
func (m *Menu) Draw(s tcell.Screen) {
defaultStyle := m.theme.GetOrDefault("Menu")
m.GetSize() // Call this to update internal width and height
DrawRect(s, m.x, m.y, m.width, m.height, ' ', defaultStyle) // Fill background
DrawRectOutlineDefault(s, m.x, m.y, m.width, m.height, defaultStyle) // Draw outline
// Draw items based on whether m.focused and which is selected
for i, item := range m.Items {
switch item.(type) {
case *ItemSeparator:
str := fmt.Sprintf("%s%s%s", "├", strings.Repeat("─", m.width-2), "┤")
DrawStr(s, m.x, m.y+1+i, str, defaultStyle)
default: // Handle sub-menus and item entries the same
var sty tcell.Style
if m.selected == i {
sty = m.theme.GetOrDefault("MenuSelected")
} else {
sty = defaultStyle
}
nameCols := DrawQuickCharStr(s, m.x+1, m.y+1+i, item.GetName(), item.GetQuickCharIdx(), sty)
str := strings.Repeat(" ", m.width-2-nameCols) // Fill space after menu names to border
DrawStr(s, m.x+1+nameCols, m.y+1+i, str, sty)
if shortcut := item.GetShortcut(); len(shortcut) > 0 { // If the item has a shortcut...
str := " " + shortcut + " "
DrawStr(s, m.x+m.width-1-runewidth.StringWidth(str), m.y+1+i, str, sty)
}
}
}
}
// SetFocused does not do anything for a Menu.
func (m *Menu) SetFocused(v bool) {
// TODO: when adding sub-menus, set all focus to v
if !v {
m.selected = 0
}
}
func (m *Menu) GetMinSize() (int, int) {
maxNameLen := 0
var widestShortcut int = 0 // Will contribute to the width
for i := range m.Items {
nameLen := len(m.Items[i].GetName())
if nameLen > maxNameLen {
maxNameLen = nameLen
}
if key := m.Items[i].GetShortcut(); runewidth.StringWidth(key) > widestShortcut {
widestShortcut = runewidth.StringWidth(key) // For the sake of good unicode
}
}
shortcutsWidth := 0
if widestShortcut > 0 {
shortcutsWidth = 1 + widestShortcut + 1 // " Ctrl+X " (with one cell padding surrounding)
}
m.width = 1 + maxNameLen + shortcutsWidth + 1 // Add two for padding
m.height = 1 + len(m.Items) + 1 // And another two for the same reason ...
return m.width, m.height
}
// GetSize returns the size of the Menu.
func (m *Menu) GetSize() (int, int) {
return m.GetMinSize()
}
// SetSize sets the size of the Menu.
func (m *Menu) SetSize(width, height int) {
// Cannot set the size of a Menu
}
func (m *Menu) handleShortcut(key string) bool {
for i := range m.Items {
switch typ := m.Items[i].(type) {
case *ItemSeparator:
continue
case *Menu:
return typ.handleShortcut(key) // Have the sub-menu handle the shortcut
case *ItemEntry:
if typ.Shortcut == key { // If this item matches the shortcut we're finding...
m.selected = i
m.ActivateItemUnderCursor() // Activate it
return true
}
}
}
return false
}
// HandleEvent will handle events for a Menu and may propogate them
// to sub-menus. Returns true if the event was handled.
func (m *Menu) HandleEvent(event tcell.Event) bool {
// TODO: simplify this function
switch ev := event.(type) {
case *tcell.EventKey:
switch ev.Key() {
case tcell.KeyEnter:
m.ActivateItemUnderCursor()
case tcell.KeyUp:
m.CursorUp()
case tcell.KeyTab:
fallthrough
case tcell.KeyDown:
m.CursorDown()
case tcell.KeyRune:
// TODO: support quick chars for sub-menus
for i, item := range m.Items {
if m.selected == i {
continue // Skip the item we're on
}
r := QuickCharInString(item.GetName(), item.GetQuickCharIdx())
if r != 0 && r == ev.Rune() {
m.selected = i
break
}
}
default:
return false
}
return true
}
return false
}

120
pkg/ui/messagedialog.go Executable file
View File

@@ -0,0 +1,120 @@
package ui
import (
"strings"
"github.com/gdamore/tcell/v2"
"github.com/mattn/go-runewidth"
)
type MessageDialogKind uint8
const (
MessageKindNormal MessageDialogKind = iota
MessageKindWarning
MessageKindError
)
// Index of messageDialogKindTitles is any MessageDialogKind.
var messageDialogKindTitles [3]string = [3]string{
"Message",
"Warning!",
"Error!",
}
type MessageDialog struct {
Title string
Kind MessageDialogKind
Callback func(string)
message string
messageWrapped string
buttons []*Button
selectedIdx int
baseComponent
}
func NewMessageDialog(title string, message string, kind MessageDialogKind, options []string, theme *Theme, callback func(string)) *MessageDialog {
if title == "" {
title = messageDialogKindTitles[kind] // Use default title
}
if options == nil || len(options) == 0 {
options = []string{"OK"}
}
dialog := MessageDialog{
Title: title,
Kind: kind,
Callback: callback,
baseComponent: baseComponent{theme: theme},
}
dialog.buttons = make([]*Button, len(options))
for i := range options {
dialog.buttons[i] = NewButton(options[i], theme, func() {
if dialog.Callback != nil {
dialog.Callback(dialog.buttons[dialog.selectedIdx].Text)
}
})
}
// Set the dialog's size to its minimum size
dialog.SetSize(0, 0)
dialog.SetMessage(message)
return &dialog
}
func (d *MessageDialog) SetMessage(message string) {
d.message = message
d.messageWrapped = runewidth.Wrap(message, d.width-2)
// Update height:
_, minHeight := d.GetMinSize()
d.height = Max(d.height, minHeight)
}
func (d *MessageDialog) Draw(s tcell.Screen) {
DrawWindow(s, d.x, d.y, d.width, d.height, d.Title, d.theme)
// DrawStr will handle '\n' characters and wrap for us.
DrawStr(s, d.x+1, d.y+2, d.messageWrapped, d.theme.GetOrDefault("Window"))
col := d.width // Start from the right side
for i := range d.buttons {
width, _ := d.buttons[i].GetSize()
col -= width + 1 // Move left enough for each button (1 for padding)
d.buttons[i].SetPos(d.x+col, d.y+d.height-2)
d.buttons[i].Draw(s)
}
}
func (d *MessageDialog) SetFocused(v bool) {
d.focused = v
d.buttons[d.selectedIdx].SetFocused(v)
}
func (d *MessageDialog) SetTheme(theme *Theme) {
d.theme = theme
for i := range d.buttons {
d.buttons[i].SetTheme(theme)
}
}
func (d *MessageDialog) GetMinSize() (int, int) {
lines := strings.Count(d.messageWrapped, "\n") + 1
return Max(len(d.Title)+2, 30), 2 + lines + 2
}
func (d *MessageDialog) SetSize(width, height int) {
minWidth, minHeight := d.GetMinSize()
d.width, d.height = Max(width, minWidth), Max(height, minHeight)
}
func (d *MessageDialog) HandleEvent(event tcell.Event) bool {
return d.buttons[d.selectedIdx].HandleEvent(event)
}

210
pkg/ui/panel.go Executable file
View File

@@ -0,0 +1,210 @@
package ui
import "github.com/gdamore/tcell/v2"
// A PanelKind describes how to interpret the fields of a Panel.
type PanelKind uint8
const (
PanelKindEmpty PanelKind = iota
PanelKindSingle // Single item. Takes up all available space
PanelKindSplitVert // Items are above or below eachother
PanelKindSplitHor // Items are left or right of eachother
)
// A Panel represents a container for a split view between two items. The Kind
// tells how to interpret the Left and Right fields. The SplitAt is the column
// between 0 and width or height, representing the position of the split between
// the Left and Right, respectively.
//
// If the Kind is equal to PanelKindEmpty, then both Left and Right are nil.
// If the Kind is equal to PanelKindSingle, then only Left has value,
// and its value will NOT be of type Panel. The SplitAt will not be used,
// as the Left will take up the whole space.
// If the Kind is equal to PanelKindSplitVert, then both Left and Right will
// have value, and they will both have to be of type Panel. The split will
// be represented vertically, and the SplitAt spans 0 to height; top to bottom,
// respectively.
// If the Kind is equal to PanelKindSplitHor, then both Left and Right will
// have value, and they will both have to be of type Panel. The split will
// be represented horizontally, and the SplitAt spans 0 to width; left to right.
type Panel struct {
Parent *Panel
Left Component
Right Component
SplitAt int
Kind PanelKind
baseComponent
}
// UpdateSplits uses the position and size of the Panel, along with its Weight
// and Kind, to appropriately size and place its children. It calls UpdateSplits()
// on its child Panels.
func (p *Panel) UpdateSplits() {
switch p.Kind {
case PanelKindSingle:
p.Left.SetPos(p.x, p.y)
p.Left.SetSize(p.width, p.height)
case PanelKindSplitVert:
p.Left.SetPos(p.x, p.y)
p.Left.SetSize(p.width, p.SplitAt)
p.Right.SetPos(p.x, p.y+p.SplitAt)
p.Right.SetSize(p.width, p.height-p.SplitAt)
p.Left.(*Panel).UpdateSplits()
p.Right.(*Panel).UpdateSplits()
case PanelKindSplitHor:
p.Left.SetPos(p.x, p.y)
p.Left.SetSize(p.SplitAt, p.height)
p.Right.SetPos(p.x+p.SplitAt, p.y)
p.Right.SetSize(p.width-p.SplitAt, p.height)
p.Left.(*Panel).UpdateSplits()
p.Right.(*Panel).UpdateSplits()
}
}
// Same as EachLeaf, but returns true if any call to `f` returned true.
func (p *Panel) eachLeaf(rightMost bool, f func(*Panel) bool) bool {
switch p.Kind {
case PanelKindEmpty:
fallthrough
case PanelKindSingle:
return f(p)
case PanelKindSplitVert:
fallthrough
case PanelKindSplitHor:
if rightMost {
if p.Right.(*Panel).eachLeaf(rightMost, f) {
return true
}
return p.Left.(*Panel).eachLeaf(rightMost, f)
} else {
if p.Left.(*Panel).eachLeaf(rightMost, f) {
return true
}
return p.Right.(*Panel).eachLeaf(rightMost, f)
}
default:
return false
}
}
// EachLeaf visits the entire tree, and calls function `f` at each leaf Panel.
// If the function `f` returns true, then visiting stops. if `rtl` is true,
// the tree is traversed in right-most order. The default is to traverse
// in left-most order.
//
// The caller of this function can safely assert that Panel's Kind is always
// either `PanelKindSingle` or `PanelKindEmpty`.
func (p *Panel) EachLeaf(rightMost bool, f func(*Panel) bool) {
p.eachLeaf(rightMost, f)
}
// IsLeaf returns whether the Panel is a leaf or not. A leaf is a panel with
// Kind `PanelKindEmpty` or `PanelKindSingle`.
func (p *Panel) IsLeaf() bool {
switch p.Kind {
case PanelKindEmpty:
fallthrough
case PanelKindSingle:
return true
default:
return false
}
}
func (p *Panel) Draw(s tcell.Screen) {
switch p.Kind {
case PanelKindSplitVert:
fallthrough
case PanelKindSplitHor:
p.Right.Draw(s)
fallthrough
case PanelKindSingle:
p.Left.Draw(s)
}
}
// SetFocused sets this Panel's Focused field to `v`. Then, if the Panel's Kind
// is PanelKindSingle, it sets its child (not a Panel) focused to `v`, also.
func (p *Panel) SetFocused(v bool) {
p.focused = v
switch p.Kind {
case PanelKindSplitVert:
fallthrough
case PanelKindSplitHor:
p.Right.SetFocused(v)
fallthrough
case PanelKindSingle:
p.Left.SetFocused(v)
}
}
func (p *Panel) SetTheme(theme *Theme) {
switch p.Kind {
case PanelKindSplitVert:
fallthrough
case PanelKindSplitHor:
p.Right.SetTheme(theme)
fallthrough
case PanelKindSingle:
p.Left.SetTheme(theme)
}
}
// GetMinSize returns the combined minimum sizes of the Panel's children.
func (p *Panel) GetMinSize() (int, int) {
switch p.Kind {
case PanelKindSingle:
return p.Left.GetMinSize()
case PanelKindSplitVert:
// use max width, add heights
lWidth, lHeight := p.Left.GetMinSize()
rWidth, rHeight := p.Right.GetMinSize()
return Max(lWidth, rWidth), lHeight + rHeight
case PanelKindSplitHor:
// use max height, add widths
lWidth, lHeight := p.Left.GetMinSize()
rWidth, rHeight := p.Right.GetMinSize()
return lWidth + rWidth, Max(lHeight, rHeight)
default:
return 0, 0
}
}
// SetSize sets the Panel size to the given width, and height. It will not check
// against GetMinSize() because it may be costly to do so. SetSize clamps the
// Panel's SplitAt to be within the new size of the Panel.
func (p *Panel) SetSize(width, height int) {
p.width, p.height = width, height
switch p.Kind {
case PanelKindSplitVert:
p.SplitAt = Min(p.SplitAt, height)
case PanelKindSplitHor:
p.SplitAt = Min(p.SplitAt, width)
}
}
// HandleEvent propogates the event to all children, calling HandleEvent()
// on left-most items. As usual: returns true if handled, false if unhandled.
// This function relies on the behavior of the child Components to only handle
// events if they are focused.
func (p *Panel) HandleEvent(event tcell.Event) bool {
switch p.Kind {
case PanelKindSingle:
return p.Left.HandleEvent(event)
case PanelKindSplitVert:
fallthrough
case PanelKindSplitHor:
if p.Left.HandleEvent(event) {
return true
}
return p.Right.HandleEvent(event)
default:
return false
}
}

337
pkg/ui/panelcontainer.go Executable file
View File

@@ -0,0 +1,337 @@
package ui
import "github.com/gdamore/tcell/v2"
type SplitKind uint8
const (
SplitVertical SplitKind = SplitKind(PanelKindSplitVert) + iota
SplitHorizontal
)
type PanelContainer struct {
root *Panel
floating []*Panel
selected **Panel // Only Panels with PanelKindSingle
lastNonFloatingSelected **Panel // Used only when focused on floating Panels
floatingMode bool // True if 'selected' is part of a floating Panel
focused bool
theme *Theme
}
func NewPanelContainer(theme *Theme) *PanelContainer {
root := &Panel{Kind: PanelKindEmpty}
return &PanelContainer{
root: root,
floating: make([]*Panel, 0, 3),
selected: &root,
theme: theme,
}
}
// ClearSelected makes the selected Panel empty, but does not delete it from
// the tree.
func (c *PanelContainer) ClearSelected() Component {
item := (**c.selected).Left
(**c.selected).Left = nil
(**c.selected).Kind = PanelKindEmpty
if p := (**c.selected).Parent; p != nil {
p.UpdateSplits()
}
return item
}
// changeSelected sets c.selected to `new`. It also refocuses the Panel.
// Prefer to use this as opposed to performing the instructions manually.
func (c *PanelContainer) changeSelected(new **Panel) {
if c.focused {
(*c.selected).SetFocused(false)
}
c.selected = new
if c.focused {
(*c.selected).SetFocused(true)
}
}
// DeleteSelected deletes the selected Panel and returns its child Component.
// If the selected Panel is the root Panel, ClearSelected() is called, instead.
func (c *PanelContainer) DeleteSelected() Component {
if !(*c.selected).IsLeaf() {
panic("selected is not leaf")
}
// If selected is the root, just make it empty
if *c.selected == c.root {
return c.ClearSelected()
} else {
item := (**c.selected).Left
p := (**c.selected).Parent
if p != nil {
if *c.selected == (*p).Left { // If we're deleting the parent's Left
(*p).Left = (*p).Right
(*p).Right = nil
} else { // Deleting parent's Right
(*p).Right = nil
}
if (*p).Left != nil {
// Parent becomes the Left panel
panel := (*p).Left.(*Panel)
(*p).Left = (*panel).Left
(*p).Right = (*panel).Right
(*p).Kind = (*panel).Kind
(*p).SplitAt = (*panel).SplitAt
} else {
(*p).Kind = PanelKindEmpty
}
// Decide what Panel to select next
if !(*p).IsLeaf() { // If the new panel was a split panel...
// Select the leftmost child of it
(*p).EachLeaf(false, func(l *Panel) bool { c.changeSelected(&l); return true })
} else {
c.changeSelected(&p)
}
(*p).UpdateSplits()
} else if c.floatingMode { // Deleting a floating Panel without a parent
c.floating[0] = nil
copy(c.floating, c.floating[1:]) // Shift items to front
c.floating = c.floating[:len(c.floating)-1] // Shrink slice's len by one
if len(c.floating) <= 0 {
c.SetFloatingFocused(false)
} else {
c.changeSelected(&c.floating[0])
}
} else {
panic("Panel does not have parent and is not floating")
}
return item
}
}
// SwapNeighborsSelected swaps two Left and Right child Panels of a vertical or
// horizontally split Panel. This is necessary to achieve a "split top" or
// "split left" effect, as Panels only split open to the bottom or right.
func (c *PanelContainer) SwapNeighborsSelected() {
parent := (**c.selected).Parent
if parent != nil {
left := (*parent).Left
(*parent).Left = parent.Right
(*parent).Right = left
parent.UpdateSplits() // Updates position and size of reordered children
}
}
// Turns the selected Panel into a split panel, moving its contents to its Left field,
// and putting the given Panel at the Right field. `panel` cannot be nil.
func (c *PanelContainer) splitSelectedWithPanel(kind SplitKind, panel *Panel) {
(**c.selected).Left = &Panel{Parent: *c.selected, Left: (**c.selected).Left, Kind: (**c.selected).Kind}
(**c.selected).Right = panel
(**c.selected).Right.(*Panel).Parent = *c.selected
// Update parent's split information
(**c.selected).Kind = PanelKind(kind)
if kind == SplitVertical {
(**c.selected).SplitAt = (**c.selected).height / 2
} else {
(**c.selected).SplitAt = (**c.selected).width / 2
}
(*c.selected).UpdateSplits()
// Change selected from parent to the previously selected Panel on the Left
panel = (**c.selected).Left.(*Panel)
c.changeSelected(&panel)
}
// SplitSelected splits the selected Panel with the given Component `item`.
// The type of split (vertical or horizontal) is determined with the `kind`.
// If `item` is nil, the new Panel will be of kind empty.
func (c *PanelContainer) SplitSelected(kind SplitKind, item Component) {
if !(*c.selected).IsLeaf() {
panic("selected is not leaf")
}
if item == nil {
c.splitSelectedWithPanel(kind, &Panel{Parent: *c.selected, Kind: PanelKindEmpty})
} else {
c.splitSelectedWithPanel(kind, &Panel{Parent: *c.selected, Left: item, Kind: PanelKindSingle})
}
}
func (c *PanelContainer) IsRootSelected() bool {
return *c.selected == c.root
}
func (c *PanelContainer) GetSelected() Component {
if !(*c.selected).IsLeaf() {
panic("selected is not leaf")
}
return (**c.selected).Left
}
func (c *PanelContainer) SetSelected(item Component) {
if !(*c.selected).IsLeaf() {
panic("selected is not leaf")
}
(**c.selected).Left = item
(**c.selected).Kind = PanelKindSingle
(*c.selected).UpdateSplits()
}
func (c *PanelContainer) raiseFloating(idx int) {
item := c.floating[idx]
copy(c.floating[1:], c.floating[:idx]) // Shift all items before idx right
c.floating[0] = item
}
// GetFloatingFocused returns true if a floating window is selected or focused.
func (c *PanelContainer) GetFloatingFocused() bool {
return c.floatingMode
}
// SetFloatingFocused sets whether the floating Panels are focused. When true,
// the current Panel will be unselected and the front floating Panel will become
// the new selected if there any floating windows. If false, the same, but the
// last selected non-floating Panel will become focused.
//
// The returned boolean is whether floating windows were able to be focused. If
// there are no floating windows when trying to focus them, this will inevitably
// return false, for example.
func (c *PanelContainer) SetFloatingFocused(v bool) bool {
if v {
if len(c.floating) > 0 {
c.lastNonFloatingSelected = c.selected
c.changeSelected(&c.floating[0])
c.floatingMode = true
return true
}
} else {
c.changeSelected(c.lastNonFloatingSelected)
c.floatingMode = false
}
return false
}
// FloatSelected makes the selected Panel floating. This function does not focus
// the newly floated Panel. To focus the floating panel, call SetFloatingFocused().
func (c *PanelContainer) FloatSelected() {
if !(*c.selected).IsLeaf() {
panic("selected is not leaf")
}
if c.floatingMode {
return
}
c.DeleteSelected()
(**c.selected).Parent = nil
(*c.selected).UpdateSplits()
c.floating = append(c.floating, *c.selected)
c.raiseFloating(len(c.floating) - 1)
}
// UnfloatSelected moves any selected floating Panel to the normal tree that is
// accessible in the standard focus mode. This function will cause focus to go to
// the normal tree if there are no remaining floating windows after the operation.
//
// Like SetFloatingFocused(), the boolean returned is whether the PanelContainer
// is focusing floating windows after the operation.
func (c *PanelContainer) UnfloatSelected(kind SplitKind) bool {
if !(*c.selected).IsLeaf() {
panic("selected is not leaf")
}
if !c.floatingMode {
return false
}
c.DeleteSelected()
c.SetFloatingFocused(false)
c.splitSelectedWithPanel(kind, *c.selected)
// Try to return to floating focus
return c.SetFloatingFocused(true)
}
func (c *PanelContainer) selectNext(rightMost bool) {
var nextIsIt bool
c.root.EachLeaf(rightMost, func(p *Panel) bool {
if nextIsIt {
c.changeSelected(&p)
nextIsIt = false
return true
} else if p == *c.selected {
nextIsIt = true
}
return false
})
// This boolean must be false if we found the next leaf.
// Therefore, if it is true, c.selected was the last leaf
// of the tree. We need to wrap around to the first leaf.
if nextIsIt {
// This gets the first leaf in left-most or right-most order
c.root.EachLeaf(rightMost, func(p *Panel) bool { c.changeSelected(&p); return true })
}
}
func (c *PanelContainer) SelectNext() {
c.selectNext(false)
}
func (c *PanelContainer) SelectPrev() {
c.selectNext(true)
}
func (c *PanelContainer) Draw(s tcell.Screen) {
c.root.Draw(s)
for i := len(c.floating) - 1; i >= 0; i-- {
c.floating[i].Draw(s)
}
}
func (c *PanelContainer) SetFocused(v bool) {
c.focused = v
(*c.selected).SetFocused(v)
}
func (c *PanelContainer) SetTheme(theme *Theme) {
c.theme = theme
c.root.SetTheme(theme)
for i := range c.floating {
c.floating[i].SetTheme(theme)
}
}
func (c *PanelContainer) GetPos() (int, int) {
return c.root.GetPos()
}
func (c *PanelContainer) SetPos(x, y int) {
c.root.SetPos(x, y)
c.root.UpdateSplits()
}
func (c *PanelContainer) GetMinSize() (int, int) {
return c.root.GetMinSize()
}
func (c *PanelContainer) GetSize() (int, int) {
return c.root.GetSize()
}
func (c *PanelContainer) SetSize(width, height int) {
c.root.SetSize(width, height)
c.root.UpdateSplits()
}
func (c *PanelContainer) HandleEvent(event tcell.Event) bool {
// Call handle event on selected Panel
return (*c.selected).HandleEvent(event)
}

195
pkg/ui/tabcontainer.go Normal file
View File

@@ -0,0 +1,195 @@
package ui
import (
"fmt"
"github.com/gdamore/tcell/v2"
)
// A Tab is a child of a TabContainer; has a name and child Component.
type Tab struct {
Name string
Child Component
}
// A TabContainer organizes children by showing only one of them at a time.
type TabContainer struct {
children []Tab
selected int
baseComponent
}
func NewTabContainer(theme *Theme) *TabContainer {
return &TabContainer{
children: make([]Tab, 0, 4),
baseComponent: baseComponent{theme: theme},
}
}
func (c *TabContainer) AddTab(name string, child Component) {
c.children = append(c.children, Tab{Name: name, Child: child})
// Update new child's size and position
child.SetPos(c.x+1, c.y+1)
child.SetSize(c.width-2, c.height-2)
}
// RemoveTab deletes the tab at `idx`. Returns true if the tab was found,
// false otherwise.
func (c *TabContainer) RemoveTab(idx int) bool {
if idx >= 0 && idx < len(c.children) {
if c.selected == idx {
c.children[idx].Child.SetFocused(false)
}
copy(c.children[idx:], c.children[idx+1:]) // Shift all items after idx to the left
c.children = c.children[:len(c.children)-1] // Shrink slice by one
if c.selected >= idx && idx > 0 {
c.selected-- // Keep the cursor within the bounds of available tabs
}
return true
}
return false
}
// FocusTab sets the visible tab to the one at `idx`. FocusTab clamps `idx`
// between 0 and tab_count - 1. If no tabs are present, the function does nothing.
func (c *TabContainer) FocusTab(idx int) {
if len(c.children) < 1 {
return
}
if idx < 0 {
idx = 0
} else if idx >= len(c.children) {
idx = len(c.children) - 1
}
c.children[c.selected].Child.SetFocused(false) // Unfocus old tab
c.children[idx].Child.SetFocused(true) // Focus new tab
c.selected = idx
}
func (c *TabContainer) GetSelectedTabIdx() int {
return c.selected
}
func (c *TabContainer) GetTabCount() int {
return len(c.children)
}
func (c *TabContainer) GetTab(idx int) *Tab {
return &c.children[idx]
}
// Draw will draws the border of the BoxContainer, then it draws its child component.
func (c *TabContainer) Draw(s tcell.Screen) {
var styFocused tcell.Style
if c.focused {
styFocused = c.theme.GetOrDefault("TabContainerFocused")
} else {
styFocused = c.theme.GetOrDefault("TabContainer")
}
// Draw outline
DrawRectOutlineDefault(s, c.x, c.y, c.width, c.height, styFocused)
combinedTabLength := 0
for i := range c.children {
combinedTabLength += len(c.children[i].Name) + 2 // 2 for padding
}
combinedTabLength += len(c.children) - 1 // add for spacing between tabs
// Draw tabs
col := c.x + c.width/2 - combinedTabLength/2 // Starting column
for i, tab := range c.children {
sty := styFocused
if c.selected == i {
fg, bg, attr := styFocused.Decompose()
sty = tcell.Style{}.Foreground(bg).Background(fg).Attributes(attr)
}
var dirty bool
switch typ := tab.Child.(type) {
case *TextEdit:
dirty = typ.Dirty
}
name := tab.Name
if dirty {
name = "*" + name
}
str := fmt.Sprintf(" %s ", name)
DrawStr(s, col, c.y, str, sty)
col += len(str) + 1 // Add one for spacing between tabs
}
// Draw selected child in center
if c.selected < len(c.children) {
c.children[c.selected].Child.Draw(s)
}
}
// SetFocused calls SetFocused on the visible child Component.
func (c *TabContainer) SetFocused(v bool) {
c.focused = v
if len(c.children) > 0 {
c.children[c.selected].Child.SetFocused(v)
}
}
// SetTheme sets the theme.
func (c *TabContainer) SetTheme(theme *Theme) {
c.theme = theme
for _, tab := range c.children {
tab.Child.SetTheme(theme) // Update the theme for all children
}
}
// SetPos sets the position of the container and updates the child Component.
func (c *TabContainer) SetPos(x, y int) {
c.x, c.y = x, y
if c.selected < len(c.children) {
c.children[c.selected].Child.SetPos(x+1, y+1)
}
}
// SetSize sets the size of the container and updates the size of the child Component.
func (c *TabContainer) SetSize(width, height int) {
c.width, c.height = width, height
if c.selected < len(c.children) {
c.children[c.selected].Child.SetSize(width-2, height-2)
}
}
// HandleEvent forwards the event to the child Component and returns whether it was handled.
func (c *TabContainer) HandleEvent(event tcell.Event) bool {
switch ev := event.(type) {
case *tcell.EventKey:
if ev.Key() == tcell.KeyCtrlE {
newIdx := c.selected + 1
if newIdx >= len(c.children) {
newIdx = 0
}
c.FocusTab(newIdx)
return true
} else if ev.Key() == tcell.KeyCtrlW {
newIdx := c.selected - 1
if newIdx < 0 {
newIdx = len(c.children) - 1
}
c.FocusTab(newIdx)
return true
}
}
if c.selected < len(c.children) {
return c.children[c.selected].Child.HandleEvent(event)
}
return false
}

643
pkg/ui/textedit.go Executable file
View File

@@ -0,0 +1,643 @@
package ui
import (
"bytes"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"unicode/utf8"
"github.com/fivemoreminix/qedit/pkg/buffer"
"github.com/gdamore/tcell/v2"
"github.com/mattn/go-runewidth"
)
// TextEdit is a field for line-based editing. It features syntax highlighting
// tools, is autocomplete ready, and contains the various information about
// content being edited.
type TextEdit struct {
Buffer buffer.Buffer
Highlighter *buffer.Highlighter
LineNumbers bool // Whether to render line numbers (and therefore the column)
Dirty bool // Whether the buffer has been edited
UseHardTabs bool // When true, tabs are '\t'
TabSize int // How many spaces to indent by
IsCRLF bool // Whether the file's line endings are CRLF (\r\n) or LF (\n)
FilePath string // Will be empty if the file has not been saved yet
screen *tcell.Screen // We keep our own reference to the screen for cursor purposes.
cursor buffer.Cursor
scrollx, scrolly int // X and Y offset of view, known as scroll
theme *Theme
selection buffer.Region // Selection: selectMode determines if it should be used
selectMode bool // Whether the user is actively selecting text
baseComponent
}
// 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 string, contents []byte, theme *Theme) *TextEdit {
te := &TextEdit{
Buffer: nil, // Set in SetContents
Highlighter: nil, // Set in SetContents
LineNumbers: true,
UseHardTabs: true,
TabSize: 4,
FilePath: filePath,
screen: screen,
baseComponent: baseComponent{theme: theme},
}
te.SetContents(contents)
return te
}
// 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 []byte) {
var i int
loop:
for i < len(contents) {
switch contents[i] {
case '\n':
t.IsCRLF = false
break loop
case '\r':
// We could check for a \n after, but what's the point?
t.IsCRLF = true
break loop
}
_, size := utf8.DecodeRune(contents[i:])
i += size
}
t.Buffer = buffer.NewRopeBuffer(contents)
t.cursor = buffer.NewCursor(&t.Buffer)
t.Buffer.RegisterCursor(&t.cursor)
t.selection = buffer.NewRegion(&t.Buffer)
// TODO: replace with automatic determination of language via filetype
lang := &buffer.Language{
Name: "Go",
Filetypes: []string{".go"},
Rules: map[*buffer.RegexpRegion]buffer.Syntax{
&buffer.RegexpRegion{Start: regexp.MustCompile("\\/\\/.*")}: buffer.Comment,
&buffer.RegexpRegion{Start: regexp.MustCompile("\".*?\"")}: buffer.String,
&buffer.RegexpRegion{
Start: regexp.MustCompile("\\b(var|const|if|else|range|for|switch|fallthrough|case|default|break|continue|go|func|return|defer|import|type|package)\\b"),
}: buffer.Keyword,
&buffer.RegexpRegion{
Start: regexp.MustCompile("\\b(u?int(8|16|32|64)?|rune|byte|string|bool|struct)\\b"),
}: buffer.Type,
&buffer.RegexpRegion{
Start: regexp.MustCompile("\\b([1-9][0-9]*|0[0-7]*|0[Xx][0-9A-Fa-f]+|0[Bb][01]+)\\b"),
}: buffer.Number,
&buffer.RegexpRegion{
Start: regexp.MustCompile("\\b(len|cap|panic|make|copy|append)\\b"),
}: buffer.Builtin,
&buffer.RegexpRegion{
Start: regexp.MustCompile("\\b(nil|true|false)\\b"),
}: buffer.Special,
},
}
colorscheme := &buffer.Colorscheme{
buffer.Default: tcell.Style{}.Foreground(tcell.ColorLightGray).Background(tcell.ColorBlack),
buffer.Column: tcell.Style{}.Foreground(tcell.ColorDarkGray).Background(tcell.ColorBlack),
buffer.Comment: tcell.Style{}.Foreground(tcell.ColorGray).Background(tcell.ColorBlack),
buffer.String: tcell.Style{}.Foreground(tcell.ColorOlive).Background(tcell.ColorBlack),
buffer.Keyword: tcell.Style{}.Foreground(tcell.ColorNavy).Background(tcell.ColorBlack),
buffer.Type: tcell.Style{}.Foreground(tcell.ColorPurple).Background(tcell.ColorBlack),
buffer.Number: tcell.Style{}.Foreground(tcell.ColorFuchsia).Background(tcell.ColorBlack),
buffer.Builtin: tcell.Style{}.Foreground(tcell.ColorBlue).Background(tcell.ColorBlack),
buffer.Special: tcell.Style{}.Foreground(tcell.ColorFuchsia).Background(tcell.ColorBlack),
}
t.Highlighter = buffer.NewHighlighter(t.Buffer, lang, colorscheme)
}
// GetLineDelimiter returns "\r\n" for a CRLF buffer, or "\n" for an LF buffer.
func (t *TextEdit) GetLineDelimiter() string {
if t.IsCRLF {
return "\r\n"
} else {
return "\n"
}
}
// 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.
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,
// while Delete with `forwards` true will delete the character after (or on) the cursor.
// In insert mode, forwards is always true.
func (t *TextEdit) Delete(forwards bool) {
t.Dirty = true
var deletedLine bool // Whether any whole line has been deleted (changing the # of lines)
cursLine, cursCol := t.cursor.GetLineCol()
startingLine := cursLine
if t.selectMode { // If text is selected, delete the whole selection
t.selectMode = false // Disable selection and prevent infinite loop
startLine, startCol := t.selection.Start.GetLineCol()
endLine, endCol := t.selection.End.GetLineCol()
// Delete the region
t.Buffer.Remove(startLine, startCol, endLine, endCol)
t.cursor.SetLineCol(startLine, startCol) // Set cursor to start of region
deletedLine = startLine != endLine
} else { // Not deleting selection
if forwards { // Delete the character after the cursor
// If the cursor is not at the end of the last line...
if cursLine < t.Buffer.Lines()-1 || cursCol < t.Buffer.RunesInLine(cursLine) {
bytes := t.Buffer.Slice(cursLine, cursCol, cursLine, cursCol) // Get the character at cursor
deletedLine = bytes[0] == '\n'
t.Buffer.Remove(cursLine, cursCol, cursLine, cursCol) // Remove character at cursor
}
} else { // Delete the character before the cursor
// If the cursor is not at the first column of the first line...
if cursLine > 0 || cursCol > 0 {
t.cursor = t.cursor.Left() // Back up to that character
cursLine, cursCol = t.cursor.GetLineCol()
bytes := t.Buffer.Slice(cursLine, cursCol, cursLine, cursCol) // Get the char at cursor
deletedLine = bytes[0] == '\n'
t.Buffer.Remove(cursLine, cursCol, cursLine, cursCol) // Remove character at cursor
}
}
}
t.ScrollToCursor()
t.updateCursorVisibility()
if deletedLine {
t.Highlighter.InvalidateLines(startingLine, t.Buffer.Lines()-1)
} else {
t.Highlighter.InvalidateLines(startingLine, startingLine)
}
}
// Writes `contents` at the cursor position. Line delimiters and tab character supported.
// Any other control characters will be printed. Overwrites any active selection.
func (t *TextEdit) Insert(contents string) {
t.Dirty = true
if t.selectMode { // If there is a selection...
// Go to and delete the selection
t.Delete(true) // The parameter doesn't matter with selection
}
var lineInserted bool // True if contents contains a '\n'
cursLine, cursCol := t.cursor.GetLineCol()
startingLine := cursLine
runes := []rune(contents)
for i := 0; i < len(runes); i++ {
ch := runes[i]
switch ch {
case '\r':
// If the character after is a \n, then it is a CRLF
if i+1 < len(runes) && runes[i+1] == '\n' {
i++ // Consume '\n' after
t.Buffer.Insert(cursLine, cursCol, []byte{'\n'})
lineInserted = true
}
case '\n':
t.Buffer.Insert(cursLine, cursCol, []byte{'\n'})
lineInserted = true
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 := strings.Repeat(" ", t.TabSize)
t.Buffer.Insert(cursLine, cursCol, []byte(spaces))
break
}
fallthrough // Append the \t character
default:
// Insert character into line
t.Buffer.Insert(cursLine, cursCol, []byte(string(ch)))
}
}
t.ScrollToCursor()
t.updateCursorVisibility()
if lineInserted {
t.Highlighter.InvalidateLines(startingLine, t.Buffer.Lines()-1)
} else {
t.Highlighter.InvalidateLines(startingLine, startingLine)
}
}
// 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 {
return t.Buffer.Count(line, 0, line, col, []byte{'\t'})
}
return 0
}
// updateCursorVisibility sets the position of the terminal's cursor with the
// cursor of the TextEdit. Sends a signal to show the cursor if the TextEdit
// is focused and not in select mode.
func (t *TextEdit) updateCursorVisibility() {
if t.focused && !t.selectMode {
columnWidth := t.getColumnWidth()
line, col := t.cursor.GetLineCol()
tabOffset := t.getTabCountInLineAtCol(line, col) * (t.TabSize - 1)
(*t.screen).ShowCursor(t.x+columnWidth+col+tabOffset-t.scrollx, t.y+line-t.scrolly)
}
}
// Scroll the screen if the cursor is out of view.
func (t *TextEdit) ScrollToCursor() {
line, col := t.cursor.GetLineCol()
// Handle hard tabs
tabOffset := t.getTabCountInLineAtCol(line, col) * (t.TabSize - 1) // Offset for the current line from hard tabs
// Scroll the screen when going to lines out of view
if line >= t.scrolly+t.height-1 { // If the new line is below view...
t.scrolly = line - t.height + 1 // Scroll just enough to view that line
} else if line < t.scrolly { // If the new line is above view
t.scrolly = line
}
columnWidth := t.getColumnWidth()
// Scroll the screen horizontally when going to columns out of view
if col+tabOffset >= t.scrollx+(t.width-columnWidth-1) { // If the new column is right of view
t.scrollx = (col + tabOffset) - (t.width - columnWidth) + 1 // Scroll just enough to view that column
} else if col+tabOffset < t.scrollx { // If the new column is left of view
t.scrollx = col + tabOffset // Scroll left enough to view that column
}
}
func (t *TextEdit) GetCursor() buffer.Cursor {
return t.cursor
}
func (t *TextEdit) SetCursor(newCursor buffer.Cursor) {
t.cursor = newCursor
t.updateCursorVisibility()
}
// getColumnWidth returns the width of the line numbers column if it is present.
func (t *TextEdit) getColumnWidth() int {
var columnWidth int
if t.LineNumbers {
// Set columnWidth to max count of line number digits
columnWidth = Max(3, 1+len(strconv.Itoa(t.Buffer.Lines()))) // Column has minimum width of 2
}
return columnWidth
}
// 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 {
// TODO: there's a bug with copying text
if t.selectMode {
startLine, startCol := t.selection.Start.GetLineCol()
endLine, endCol := t.selection.End.GetLineCol()
return t.Buffer.Slice(startLine, startCol, endLine, endCol)
}
return []byte{}
}
// Draw renders the TextEdit component.
func (t *TextEdit) Draw(s tcell.Screen) {
columnWidth := t.getColumnWidth()
bufferLines := t.Buffer.Lines()
selectedStyle := t.theme.GetOrDefault("TextEditSelected")
columnStyle := t.Highlighter.Colorscheme.GetStyle(buffer.Column)
t.Highlighter.UpdateInvalidatedLines(t.scrolly, t.scrolly+(t.height-1))
var tabBytes []byte
if t.UseHardTabs {
// Only call Repeat once for each draw in hard tab files
tabBytes = bytes.Repeat([]byte{' '}, t.TabSize)
}
defaultStyle := t.Highlighter.Colorscheme.GetStyle(buffer.Default)
currentStyle := defaultStyle
for lineY := t.y; lineY < t.y+t.height; lineY++ { // For each line we can draw...
line := lineY + t.scrolly - t.y // The line number being drawn (starts at zero)
lineNumStr := "" // Line number as a string
if line < bufferLines { // Only index buffer if we are within it...
lineNumStr = strconv.Itoa(line + 1) // Only set for lines within the buffer (not view)
var origLineBytes []byte = t.Buffer.Line(line)
var lineBytes []byte = origLineBytes // Line to be drawn
if t.UseHardTabs {
lineBytes = bytes.ReplaceAll(lineBytes, []byte{'\t'}, tabBytes)
}
lineHighlightData := t.Highlighter.GetLineMatches(line)
var lineHighlightDataIdx int
var byteIdx int // Byte index of lineStr
var runeIdx int // Index into lineStr (as runes) we draw the next character at
col := t.x + columnWidth // X offset we draw the next rune at (some runes can be 2 cols wide)
for runeIdx < t.scrollx && byteIdx < len(lineBytes) {
_, size := utf8.DecodeRune(lineBytes[byteIdx:]) // Respect UTF-8
byteIdx += size
runeIdx++
}
tabOffsetAtRuneIdx := func(idx int) int {
var count int
for _, r := range string(origLineBytes) {
if r == '\t' {
count++
}
}
return count * (t.TabSize - 1)
}
// origRuneIdx converts a rune index from lineBytes to a runeIndex from origLineBytes
// not affected by the hard tabs becoming 4 or 8 spaces.
origRuneIdx := func(idx int) int { // returns the idx that is not mutated by hard tabs
var ridx int // new rune idx
for _, r := range string(origLineBytes) {
if idx <= 0 {
break
}
if r == '\t' {
idx -= t.TabSize
} else {
idx--
}
if idx >= 0 { // causes ridx = 0, when idx = 3
ridx++
}
}
return ridx
}
for col < t.x+t.width { // For each column in view...
var r rune = ' ' // Rune to draw this iteration
var size int = 1 // Size of the rune (in bytes)
var selected bool // Whether this rune should be styled as selected
tabOffsetAtRuneIdx := tabOffsetAtRuneIdx(runeIdx)
if byteIdx < len(lineBytes) { // If we are drawing part of the line contents...
r, size = utf8.DecodeRune(lineBytes[byteIdx:])
if r == '\n' {
r = ' '
}
startLine, startCol := t.selection.Start.GetLineCol()
endLine, endCol := t.selection.End.GetLineCol()
// Determine whether we select the current rune. Also only select runes within
// the line bytes range.
if t.selectMode && line >= startLine && line <= endLine { // If we're part of a selection...
_origRuneIdx := origRuneIdx(runeIdx)
if line == startLine { // If selection starts at this line...
if _origRuneIdx >= startCol { // And we're at or past the start col...
// If the start line is also the end line...
if line == endLine {
if _origRuneIdx <= endCol { // And we're before the end of that...
selected = true
}
} else { // Definitely highlight
selected = true
}
}
} else if line == endLine { // If selection ends at this line...
if _origRuneIdx <= endCol { // And we're at or before the end col...
selected = true
}
} else { // We're between the start and the end lines, definitely highlight.
selected = true
}
}
}
// Determine the style of the rune we draw next:
if selected {
currentStyle = selectedStyle
} else {
currentStyle = defaultStyle
if lineHighlightDataIdx < len(lineHighlightData) { // Works for single-line highlights
data := lineHighlightData[lineHighlightDataIdx]
if runeIdx-tabOffsetAtRuneIdx >= data.Col {
if runeIdx-tabOffsetAtRuneIdx > data.EndCol { // Passed that highlight data
currentStyle = defaultStyle
lineHighlightDataIdx++ // Go to next one
} else { // Start coloring as this syntax style
currentStyle = t.Highlighter.Colorscheme.GetStyle(data.Syntax)
}
}
}
}
// Draw the rune
s.SetContent(col, lineY, r, nil, currentStyle)
col += runewidth.RuneWidth(r)
byteIdx += size
runeIdx++
}
}
columnStr := fmt.Sprintf("%s%s│", strings.Repeat(" ", columnWidth-len(lineNumStr)-1), lineNumStr) // Right align line number
DrawStr(s, t.x, lineY, columnStr, columnStyle) // Draw column
}
t.updateCursorVisibility()
}
// SetFocused sets whether the TextEdit is focused. When focused, the cursor is set visible
// and its position is updated on every event.
func (t *TextEdit) SetFocused(v bool) {
t.focused = v
if v {
t.updateCursorVisibility()
} else {
(*t.screen).HideCursor()
}
}
// HandleEvent allows the TextEdit to handle `event` if it chooses, returns
// whether the TextEdit handled the event.
func (t *TextEdit) HandleEvent(event tcell.Event) bool {
switch ev := event.(type) {
case *tcell.EventKey:
switch ev.Key() {
// Cursor movement
case tcell.KeyUp:
if ev.Modifiers() == tcell.ModShift {
if !t.selectMode {
var endCursor buffer.Cursor
if cursLine, _ := t.cursor.GetLineCol(); cursLine != 0 {
endCursor = t.cursor.Left()
} else {
endCursor = t.cursor
}
t.selection.End = endCursor
t.SetCursor(t.cursor.Up())
t.selection.Start = t.cursor
t.selectMode = true
t.ScrollToCursor()
break // Select only a single character at start
}
if t.selection.Start.Eq(t.cursor) {
t.SetCursor(t.cursor.Up())
t.selection.Start = t.cursor
} else {
t.SetCursor(t.cursor.Up())
t.selection.End = t.cursor
}
} else {
t.selectMode = false
t.SetCursor(t.cursor.Up())
}
t.ScrollToCursor()
case tcell.KeyDown:
if ev.Modifiers() == tcell.ModShift {
if !t.selectMode {
t.selection.Start = t.cursor
t.SetCursor(t.cursor.Down())
t.selection.End = t.cursor
t.selectMode = true
t.ScrollToCursor()
break
}
if t.selection.End.Eq(t.cursor) {
t.SetCursor(t.cursor.Down())
t.selection.End = t.cursor
} else {
t.SetCursor(t.cursor.Down())
t.selection.Start = t.cursor
}
} else {
t.selectMode = false
t.SetCursor(t.cursor.Down())
}
t.ScrollToCursor()
case tcell.KeyLeft:
if ev.Modifiers() == tcell.ModShift {
if !t.selectMode {
t.SetCursor(t.cursor.Left())
t.selection.Start, t.selection.End = t.cursor, t.cursor
t.selectMode = true
t.ScrollToCursor()
break // Select only a single character at start
}
if t.selection.Start.Eq(t.cursor) {
t.SetCursor(t.cursor.Left())
t.selection.Start = t.cursor
} else {
t.SetCursor(t.cursor.Left())
t.selection.End = t.cursor
}
} else {
t.selectMode = false
t.SetCursor(t.cursor.Left())
}
t.ScrollToCursor()
case tcell.KeyRight:
if ev.Modifiers() == tcell.ModShift {
if !t.selectMode {
t.selection.Start, t.selection.End = t.cursor, t.cursor
t.selectMode = true
break
}
if t.selection.End.Eq(t.cursor) {
t.SetCursor(t.cursor.Right())
t.selection.End = t.cursor
} else {
t.SetCursor(t.cursor.Right())
t.selection.Start = t.cursor
}
} else {
t.selectMode = false
t.SetCursor(t.cursor.Right())
}
t.ScrollToCursor()
case tcell.KeyHome:
cursLine, _ := t.cursor.GetLineCol()
// TODO: go to first (non-whitespace) character on current line, if we are not already there
// otherwise actually go to first (0) character of the line
t.SetCursor(t.cursor.SetLineCol(cursLine, 0))
t.ScrollToCursor()
case tcell.KeyEnd:
cursLine, _ := t.cursor.GetLineCol()
t.SetCursor(t.cursor.SetLineCol(cursLine, math.MaxInt32)) // Max column
t.ScrollToCursor()
case tcell.KeyPgUp:
_, cursCol := t.cursor.GetLineCol()
t.SetCursor(t.cursor.SetLineCol(t.scrolly-t.height, cursCol)) // Go a page up
t.ScrollToCursor()
case tcell.KeyPgDn:
_, cursCol := t.cursor.GetLineCol()
t.SetCursor(t.cursor.SetLineCol(t.scrolly+t.height*2-1, cursCol)) // Go a page down
t.ScrollToCursor()
// Deleting
case tcell.KeyBackspace:
fallthrough
case tcell.KeyBackspace2:
t.Delete(false)
case tcell.KeyDelete:
t.Delete(true)
// Other control
case tcell.KeyTab:
t.Insert("\t") // (can translate to four spaces)
case tcell.KeyEnter:
t.Insert("\n")
// Inserting
case tcell.KeyRune:
t.Insert(string(ev.Rune())) // Insert rune
default:
return false
}
return true
}
return false
}

45
pkg/ui/theme.go Normal file
View File

@@ -0,0 +1,45 @@
package ui
import (
"fmt"
"github.com/gdamore/tcell/v2"
)
// A Theme is a map of string names to styles. Themes can be passed by reference to components
// to set their styles. Some components will depend upon the basic keys, but most components
// may use keys specific to their component. If a theme value cannot be found, then the
// `DefaultTheme` value will be used, instead. An updated list of theme keys can be found on
// the default theme.
type Theme map[string]tcell.Style
func (theme *Theme) GetOrDefault(key string) tcell.Style {
if theme != nil {
if val, ok := (*theme)[key]; ok {
return val
}
}
if val, ok := DefaultTheme[key]; ok {
return val
} else {
panic(fmt.Sprintf("key \"%v\" not present in default theme", key))
}
}
// DefaultTheme uses only the first 16 colors present in most colored terminals.
var DefaultTheme = Theme{
"Normal": tcell.Style{}.Foreground(tcell.ColorSilver).Background(tcell.ColorBlack),
"Button": tcell.Style{}.Foreground(tcell.ColorBlack).Background(tcell.ColorSilver),
"InputField": tcell.Style{}.Foreground(tcell.ColorSilver).Background(tcell.ColorBlack),
"MenuBar": tcell.Style{}.Foreground(tcell.ColorBlack).Background(tcell.ColorDarkGray),
"MenuBarFocused": tcell.Style{}.Foreground(tcell.ColorBlack).Background(tcell.ColorLightGray),
"Menu": tcell.Style{}.Foreground(tcell.ColorBlack).Background(tcell.ColorSilver),
"MenuSelected": tcell.Style{}.Foreground(tcell.ColorSilver).Background(tcell.ColorBlack),
"TabContainer": tcell.Style{}.Foreground(tcell.ColorGray).Background(tcell.ColorBlack),
"TabContainerFocused": tcell.Style{}.Foreground(tcell.ColorSilver).Background(tcell.ColorBlack),
"TextEdit": tcell.Style{}.Foreground(tcell.ColorSilver).Background(tcell.ColorBlack),
"TextEditSelected": tcell.Style{}.Foreground(tcell.ColorBlack).Background(tcell.ColorSilver),
"Window": tcell.Style{}.Foreground(tcell.ColorBlack).Background(tcell.ColorDarkGray),
"WindowHeader": tcell.Style{}.Foreground(tcell.ColorBlack).Background(tcell.ColorSilver),
}

49
pkg/ui/util.go Normal file
View File

@@ -0,0 +1,49 @@
package ui
import (
"unicode"
"unicode/utf8"
)
// QuickCharInString is used for finding the "quick char" in a string. The rune
// is always made lowercase. A rune of value zero is returned if the index was
// less than zero, or greater or equal to, the number of runes in s.
func QuickCharInString(s string, idx int) rune {
if idx < 0 {
return 0
}
var runeIdx int
bytes := []byte(s)
for i := 0; i < len(bytes); runeIdx++ { // i is a byte index
r, size := utf8.DecodeRune(bytes[i:])
if runeIdx == idx {
return unicode.ToLower(r)
}
i += size
}
return 0
}
// Max returns the larger integer.
func Max(a, b int) int {
if a > b {
return a
}
return b
}
// Min returns the smaller integer.
func Min(a, b int) int {
if a < b {
return a
}
return b
}
// Clamp keeps `v` within `a` and `b` numerically. `a` must be smaller than `b`.
// Returns clamped `v`.
func Clamp(v, a, b int) int {
return Max(a, Min(v, b))
}