Changed project layout and updated readme

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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