Changed project layout and updated readme
This commit is contained in:
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