171fefd209
The Tabs object exposes an array of Tab objects and the current selected index in that array. The these two fields are sometimes modified in goroutines, which can lead to data races causing fatal out of bounds accesses on the tab array. Hide these fields as private API. Expose only what needs to be seen from the outside. This will prepare for protecting concurrent access with a lock in the next commit. Signed-off-by: Robin Jarry <robin@jarry.cc> Acked-by: Koni Marti <koni.marti@gmail.com>
447 lines
9.1 KiB
Go
447 lines
9.1 KiB
Go
package ui
|
|
|
|
import (
|
|
"io"
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/mattn/go-runewidth"
|
|
|
|
"git.sr.ht/~rjarry/aerc/config"
|
|
)
|
|
|
|
type Tabs struct {
|
|
tabs []*Tab
|
|
TabStrip *TabStrip
|
|
TabContent *TabContent
|
|
curIndex int
|
|
history []int
|
|
|
|
uiConfig *config.UIConfig
|
|
|
|
onInvalidateStrip func(d Drawable)
|
|
onInvalidateContent func(d Drawable)
|
|
|
|
parent *Tabs
|
|
CloseTab func(index int)
|
|
}
|
|
|
|
type Tab struct {
|
|
Content Drawable
|
|
Name string
|
|
invalid bool
|
|
pinned bool
|
|
indexBeforePin int
|
|
uiConf *config.UIConfig
|
|
}
|
|
|
|
type TabStrip Tabs
|
|
type TabContent Tabs
|
|
|
|
func NewTabs(uiConf *config.UIConfig) *Tabs {
|
|
tabs := &Tabs{}
|
|
tabs.uiConfig = uiConf
|
|
tabs.TabStrip = (*TabStrip)(tabs)
|
|
tabs.TabStrip.parent = tabs
|
|
tabs.TabContent = (*TabContent)(tabs)
|
|
tabs.TabContent.parent = tabs
|
|
tabs.history = []int{}
|
|
return tabs
|
|
}
|
|
|
|
func (tabs *Tabs) Add(content Drawable, name string, uiConf *config.UIConfig) *Tab {
|
|
tab := &Tab{
|
|
Content: content,
|
|
Name: name,
|
|
uiConf: uiConf,
|
|
}
|
|
tabs.tabs = append(tabs.tabs, tab)
|
|
tabs.Select(len(tabs.tabs) - 1)
|
|
content.OnInvalidate(tabs.invalidateChild)
|
|
return tab
|
|
}
|
|
|
|
func (tabs *Tabs) Names() []string {
|
|
var names []string
|
|
for _, tab := range tabs.tabs {
|
|
names = append(names, tab.Name)
|
|
}
|
|
return names
|
|
}
|
|
|
|
func (tabs *Tabs) invalidateChild(d Drawable) {
|
|
if tabs.curIndex >= len(tabs.tabs) {
|
|
return
|
|
}
|
|
|
|
if tabs.tabs[tabs.curIndex].Content == d {
|
|
if tabs.onInvalidateContent != nil {
|
|
tabs.onInvalidateContent(tabs.TabContent)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (tabs *Tabs) Remove(content Drawable) {
|
|
indexToRemove := -1
|
|
for i, tab := range tabs.tabs {
|
|
if tab.Content == content {
|
|
tabs.tabs = append(tabs.tabs[:i], tabs.tabs[i+1:]...)
|
|
tabs.removeHistory(i)
|
|
indexToRemove = i
|
|
break
|
|
}
|
|
}
|
|
if indexToRemove < 0 {
|
|
return
|
|
}
|
|
// only pop the tab history if the closing tab is selected
|
|
if indexToRemove == tabs.curIndex {
|
|
index, ok := tabs.popHistory()
|
|
if ok {
|
|
tabs.selectPriv(index)
|
|
}
|
|
} else if indexToRemove < tabs.curIndex {
|
|
// selected tab is now one to the left of where it was
|
|
tabs.Select(tabs.curIndex - 1)
|
|
}
|
|
interactive, ok := tabs.tabs[tabs.curIndex].Content.(Interactive)
|
|
if ok {
|
|
interactive.Focus(true)
|
|
}
|
|
}
|
|
|
|
func (tabs *Tabs) Replace(contentSrc Drawable, contentTarget Drawable, name string) {
|
|
replaceTab := &Tab{
|
|
Content: contentTarget,
|
|
Name: name,
|
|
}
|
|
for i, tab := range tabs.tabs {
|
|
if tab.Content == contentSrc {
|
|
tabs.tabs[i] = replaceTab
|
|
tabs.Select(i)
|
|
if c, ok := contentSrc.(io.Closer); ok {
|
|
c.Close()
|
|
}
|
|
break
|
|
}
|
|
}
|
|
tabs.TabStrip.Invalidate()
|
|
contentTarget.OnInvalidate(tabs.invalidateChild)
|
|
}
|
|
|
|
func (tabs *Tabs) Get(index int) *Tab {
|
|
if index < 0 || index >= len(tabs.tabs) {
|
|
return nil
|
|
}
|
|
return tabs.tabs[index]
|
|
}
|
|
|
|
func (tabs *Tabs) Selected() *Tab {
|
|
if tabs.curIndex < 0 || tabs.curIndex >= len(tabs.tabs) {
|
|
return nil
|
|
}
|
|
return tabs.tabs[tabs.curIndex]
|
|
}
|
|
|
|
func (tabs *Tabs) Select(index int) bool {
|
|
if index < 0 || index >= len(tabs.tabs) {
|
|
return false
|
|
}
|
|
|
|
if tabs.curIndex != index {
|
|
// only push valid tabs onto the history
|
|
if tabs.curIndex < len(tabs.tabs) {
|
|
tabs.pushHistory(tabs.curIndex)
|
|
}
|
|
tabs.curIndex = index
|
|
tabs.TabStrip.Invalidate()
|
|
tabs.TabContent.Invalidate()
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (tabs *Tabs) SelectName(name string) bool {
|
|
for i, tab := range tabs.tabs {
|
|
if tab.Name == name {
|
|
return tabs.Select(i)
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (tabs *Tabs) SelectPrevious() bool {
|
|
index, ok := tabs.popHistory()
|
|
if !ok {
|
|
return false
|
|
}
|
|
return tabs.Select(index)
|
|
}
|
|
|
|
func (tabs *Tabs) MoveTab(to int, relative bool) {
|
|
from := tabs.curIndex
|
|
|
|
if relative {
|
|
to = from + to
|
|
}
|
|
if to < 0 {
|
|
to = 0
|
|
}
|
|
if to >= len(tabs.tabs) {
|
|
to = len(tabs.tabs) - 1
|
|
}
|
|
|
|
tab := tabs.tabs[from]
|
|
if to > from {
|
|
copy(tabs.tabs[from:to], tabs.tabs[from+1:to+1])
|
|
for i, h := range tabs.history {
|
|
if h == from {
|
|
tabs.history[i] = to
|
|
}
|
|
if h > from && h <= to {
|
|
tabs.history[i] -= 1
|
|
}
|
|
}
|
|
} else if from > to {
|
|
copy(tabs.tabs[to+1:from+1], tabs.tabs[to:from])
|
|
for i, h := range tabs.history {
|
|
if h == from {
|
|
tabs.history[i] = to
|
|
}
|
|
if h >= to && h < from {
|
|
tabs.history[i] += 1
|
|
}
|
|
}
|
|
} else {
|
|
return
|
|
}
|
|
|
|
tabs.tabs[to] = tab
|
|
tabs.curIndex = to
|
|
tabs.TabStrip.Invalidate()
|
|
}
|
|
|
|
func (tabs *Tabs) PinTab() {
|
|
if tabs.tabs[tabs.curIndex].pinned {
|
|
return
|
|
}
|
|
|
|
pinEnd := len(tabs.tabs)
|
|
for i, t := range tabs.tabs {
|
|
if !t.pinned {
|
|
pinEnd = i
|
|
break
|
|
}
|
|
}
|
|
|
|
for _, t := range tabs.tabs {
|
|
if t.pinned && t.indexBeforePin > tabs.curIndex-pinEnd {
|
|
t.indexBeforePin -= 1
|
|
}
|
|
}
|
|
|
|
tabs.tabs[tabs.curIndex].pinned = true
|
|
tabs.tabs[tabs.curIndex].indexBeforePin = tabs.curIndex - pinEnd
|
|
|
|
tabs.MoveTab(pinEnd, false)
|
|
}
|
|
|
|
func (tabs *Tabs) UnpinTab() {
|
|
if !tabs.tabs[tabs.curIndex].pinned {
|
|
return
|
|
}
|
|
|
|
pinEnd := len(tabs.tabs)
|
|
for i, t := range tabs.tabs {
|
|
if i != tabs.curIndex && t.pinned && t.indexBeforePin > tabs.tabs[tabs.curIndex].indexBeforePin {
|
|
t.indexBeforePin += 1
|
|
}
|
|
if !t.pinned {
|
|
pinEnd = i
|
|
break
|
|
}
|
|
}
|
|
|
|
tabs.tabs[tabs.curIndex].pinned = false
|
|
|
|
tabs.MoveTab(tabs.tabs[tabs.curIndex].indexBeforePin+pinEnd-1, false)
|
|
}
|
|
|
|
func (tabs *Tabs) NextTab() {
|
|
next := tabs.curIndex + 1
|
|
if next >= len(tabs.tabs) {
|
|
next = 0
|
|
}
|
|
tabs.Select(next)
|
|
}
|
|
|
|
func (tabs *Tabs) PrevTab() {
|
|
next := tabs.curIndex - 1
|
|
if next < 0 {
|
|
next = len(tabs.tabs) - 1
|
|
}
|
|
tabs.Select(next)
|
|
}
|
|
|
|
func (tabs *Tabs) pushHistory(index int) {
|
|
tabs.history = append(tabs.history, index)
|
|
}
|
|
|
|
func (tabs *Tabs) popHistory() (int, bool) {
|
|
lastIdx := len(tabs.history) - 1
|
|
if lastIdx < 0 {
|
|
return 0, false
|
|
}
|
|
item := tabs.history[lastIdx]
|
|
tabs.history = tabs.history[:lastIdx]
|
|
return item, true
|
|
}
|
|
|
|
func (tabs *Tabs) removeHistory(index int) {
|
|
newHist := make([]int, 0, len(tabs.history))
|
|
for i, item := range tabs.history {
|
|
if item == index {
|
|
continue
|
|
}
|
|
if item > index {
|
|
item = item - 1
|
|
}
|
|
// dedup
|
|
if i > 0 && len(newHist) > 0 && item == newHist[len(newHist)-1] {
|
|
continue
|
|
}
|
|
newHist = append(newHist, item)
|
|
}
|
|
tabs.history = newHist
|
|
}
|
|
|
|
// TODO: Color repository
|
|
func (strip *TabStrip) Draw(ctx *Context) {
|
|
x := 0
|
|
for i, tab := range strip.tabs {
|
|
uiConfig := strip.uiConfig
|
|
if tab.uiConf != nil {
|
|
uiConfig = tab.uiConf
|
|
}
|
|
style := uiConfig.GetStyle(config.STYLE_TAB)
|
|
if strip.curIndex == i {
|
|
style = uiConfig.GetStyleSelected(config.STYLE_TAB)
|
|
}
|
|
tabWidth := 32
|
|
if ctx.Width()-x < tabWidth {
|
|
tabWidth = ctx.Width() - x - 2
|
|
}
|
|
name := tab.Name
|
|
if tab.pinned {
|
|
name = uiConfig.PinnedTabMarker + name
|
|
}
|
|
trunc := runewidth.Truncate(name, tabWidth, "…")
|
|
x += ctx.Printf(x, 0, style, " %s ", trunc)
|
|
if x >= ctx.Width() {
|
|
break
|
|
}
|
|
}
|
|
ctx.Fill(x, 0, ctx.Width()-x, 1, ' ',
|
|
strip.uiConfig.GetStyle(config.STYLE_TAB))
|
|
}
|
|
|
|
func (strip *TabStrip) Invalidate() {
|
|
if strip.onInvalidateStrip != nil {
|
|
strip.onInvalidateStrip(strip)
|
|
}
|
|
}
|
|
|
|
func (strip *TabStrip) MouseEvent(localX int, localY int, event tcell.Event) {
|
|
changeFocus := func(focus bool) {
|
|
interactive, ok := strip.parent.tabs[strip.parent.curIndex].Content.(Interactive)
|
|
if ok {
|
|
interactive.Focus(focus)
|
|
}
|
|
}
|
|
unfocus := func() { changeFocus(false) }
|
|
refocus := func() { changeFocus(true) }
|
|
switch event := event.(type) {
|
|
case *tcell.EventMouse:
|
|
switch event.Buttons() {
|
|
case tcell.Button1:
|
|
selectedTab, ok := strip.clicked(localX, localY)
|
|
if !ok || selectedTab == strip.parent.curIndex {
|
|
return
|
|
}
|
|
unfocus()
|
|
strip.parent.Select(selectedTab)
|
|
refocus()
|
|
case tcell.WheelDown:
|
|
unfocus()
|
|
strip.parent.NextTab()
|
|
refocus()
|
|
case tcell.WheelUp:
|
|
unfocus()
|
|
strip.parent.PrevTab()
|
|
refocus()
|
|
case tcell.Button3:
|
|
selectedTab, ok := strip.clicked(localX, localY)
|
|
if !ok {
|
|
return
|
|
}
|
|
unfocus()
|
|
strip.parent.CloseTab(selectedTab)
|
|
refocus()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (strip *TabStrip) OnInvalidate(onInvalidate func(d Drawable)) {
|
|
strip.onInvalidateStrip = onInvalidate
|
|
}
|
|
|
|
func (strip *TabStrip) clicked(mouseX int, mouseY int) (int, bool) {
|
|
x := 0
|
|
for i, tab := range strip.tabs {
|
|
trunc := runewidth.Truncate(tab.Name, 32, "…")
|
|
length := len(trunc) + 2
|
|
if x <= mouseX && mouseX < x+length {
|
|
return i, true
|
|
}
|
|
x += length
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func (content *TabContent) Children() []Drawable {
|
|
children := make([]Drawable, len(content.tabs))
|
|
for i, tab := range content.tabs {
|
|
children[i] = tab.Content
|
|
}
|
|
return children
|
|
}
|
|
|
|
func (content *TabContent) Draw(ctx *Context) {
|
|
if content.curIndex >= len(content.tabs) {
|
|
width := ctx.Width()
|
|
height := ctx.Height()
|
|
ctx.Fill(0, 0, width, height, ' ',
|
|
content.uiConfig.GetStyle(config.STYLE_TAB))
|
|
}
|
|
|
|
tab := content.tabs[content.curIndex]
|
|
tab.Content.Draw(ctx)
|
|
}
|
|
|
|
func (content *TabContent) MouseEvent(localX int, localY int, event tcell.Event) {
|
|
tab := content.tabs[content.curIndex]
|
|
switch tabContent := tab.Content.(type) {
|
|
case Mouseable:
|
|
tabContent.MouseEvent(localX, localY, event)
|
|
}
|
|
}
|
|
|
|
func (content *TabContent) Invalidate() {
|
|
if content.onInvalidateContent != nil {
|
|
content.onInvalidateContent(content)
|
|
}
|
|
tab := content.tabs[content.curIndex]
|
|
tab.Content.Invalidate()
|
|
}
|
|
|
|
func (content *TabContent) OnInvalidate(onInvalidate func(d Drawable)) {
|
|
content.onInvalidateContent = onInvalidate
|
|
}
|