Changed project layout and updated readme
This commit is contained in:
90
pkg/buffer/buffer.go
Executable file
90
pkg/buffer/buffer.go
Executable 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
103
pkg/buffer/cursor.go
Executable 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
193
pkg/buffer/highlighter.go
Executable 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
23
pkg/buffer/language.go
Executable 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
358
pkg/buffer/rope.go
Executable 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
148
pkg/buffer/rope_test.go
Normal 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
23
pkg/buffer/util.go
Normal 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
58
pkg/ui/button.go
Normal 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
82
pkg/ui/component.go
Normal 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
92
pkg/ui/drawfunctions.go
Normal 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)
|
129
pkg/ui/fileselectordialog.go
Normal file
129
pkg/ui/fileselectordialog.go
Normal 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
191
pkg/ui/inputfield.go
Normal 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
25
pkg/ui/label.go
Normal 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
468
pkg/ui/menu.go
Normal 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
120
pkg/ui/messagedialog.go
Executable 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
210
pkg/ui/panel.go
Executable 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
337
pkg/ui/panelcontainer.go
Executable 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
195
pkg/ui/tabcontainer.go
Normal 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
643
pkg/ui/textedit.go
Executable 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
45
pkg/ui/theme.go
Normal 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
49
pkg/ui/util.go
Normal 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))
|
||||
}
|
Reference in New Issue
Block a user