package ui import ( "sync" "github.com/mattn/go-runewidth" "git.sr.ht/~rjarry/aerc/config" "git.sr.ht/~rockorager/vaxis" ) const tabRuneWidth int = 32 // TODO: make configurable type Tabs struct { tabs []*Tab TabStrip *TabStrip TabContent *TabContent curIndex int history []*Tab m sync.Mutex ui func(d Drawable) *config.UIConfig parent *Tabs //nolint:structcheck // used within this file CloseTab func(index int) } type Tab struct { Content Drawable Name string pinned bool indexBeforePin int title string } func (t *Tab) SetTitle(s string) { t.title = s } func (t *Tab) displayName(pinMarker string) string { name := t.Name if t.title != "" { name = t.title } if t.pinned { name = pinMarker + name } return name } type ( TabStrip Tabs TabContent Tabs ) func NewTabs(ui func(d Drawable) *config.UIConfig) *Tabs { tabs := &Tabs{ui: ui} tabs.TabStrip = (*TabStrip)(tabs) tabs.TabStrip.parent = tabs tabs.TabContent = (*TabContent)(tabs) tabs.TabContent.parent = tabs return tabs } func (tabs *Tabs) Add(content Drawable, name string, background bool) *Tab { tab := &Tab{ Content: content, Name: name, } tabs.tabs = append(tabs.tabs, tab) if !background { tabs.selectPriv(len(tabs.tabs)-1, true) } return tab } func (tabs *Tabs) Names() []string { var names []string tabs.m.Lock() for _, tab := range tabs.tabs { names = append(names, tab.Name) } tabs.m.Unlock() return names } func (tabs *Tabs) Remove(content Drawable) { tabs.m.Lock() defer tabs.m.Unlock() index := -1 for i, tab := range tabs.tabs { if tab.Content == content { index = i break } } if index == -1 { return } tab := tabs.tabs[index] if vis, ok := tab.Content.(Visible); ok { vis.Show(false) } if vis, ok := tab.Content.(Interactive); ok { vis.Focus(false) } tabs.tabs = append(tabs.tabs[:index], tabs.tabs[index+1:]...) tabs.removeHistory(tab) if index == tabs.curIndex { // only pop the tab history if the closing tab is selected prevIndex, ok := tabs.popHistory() if !ok { if tabs.curIndex < len(tabs.tabs) { // history is empty, select tab on the right if possible prevIndex = tabs.curIndex } else { // if removing the last tab, select the now last tab prevIndex = len(tabs.tabs) - 1 } } tabs.selectPriv(prevIndex, false) } else if index < tabs.curIndex { // selected tab is now one to the left of where it was tabs.selectPriv(tabs.curIndex-1, false) } Invalidate() } func (tabs *Tabs) Replace(contentSrc Drawable, contentTarget Drawable, name string) { tabs.m.Lock() defer tabs.m.Unlock() for i, tab := range tabs.tabs { if tab.Content == contentSrc { if vis, ok := tab.Content.(Visible); ok { vis.Show(false) } if vis, ok := tab.Content.(Interactive); ok { vis.Focus(false) } tab.Content = contentTarget tabs.selectPriv(i, false) Invalidate() break } } } func (tabs *Tabs) Get(index int) *Tab { tabs.m.Lock() defer tabs.m.Unlock() if index < 0 || index >= len(tabs.tabs) { return nil } return tabs.tabs[index] } func (tabs *Tabs) Selected() *Tab { tabs.m.Lock() defer tabs.m.Unlock() if tabs.curIndex < 0 || tabs.curIndex >= len(tabs.tabs) { return nil } return tabs.tabs[tabs.curIndex] } func (tabs *Tabs) Select(index int) bool { tabs.m.Lock() defer tabs.m.Unlock() return tabs.selectPriv(index, true) } func (tabs *Tabs) selectPriv(index int, unselectPrev bool) bool { if index < 0 || index >= len(tabs.tabs) { return false } // only push valid tabs onto the history if unselectPrev && tabs.curIndex < len(tabs.tabs) { prev := tabs.tabs[tabs.curIndex] if vis, ok := prev.Content.(Visible); ok { vis.Show(false) } if vis, ok := prev.Content.(Interactive); ok { vis.Focus(false) } tabs.pushHistory(prev) } next := tabs.tabs[index] if vis, ok := next.Content.(Visible); ok { vis.Show(true) } if vis, ok := next.Content.(Interactive); ok { vis.Focus(true) } tabs.curIndex = index Invalidate() return true } func (tabs *Tabs) SelectName(name string) bool { tabs.m.Lock() defer tabs.m.Unlock() for i, tab := range tabs.tabs { if tab.Name == name { return tabs.selectPriv(i, true) } } return false } func (tabs *Tabs) SelectPrevious() bool { tabs.m.Lock() defer tabs.m.Unlock() index, ok := tabs.popHistory() if !ok { return false } return tabs.selectPriv(index, true) } func (tabs *Tabs) SelectOffset(offset int) { tabs.m.Lock() tabCount := len(tabs.tabs) newIndex := (tabs.curIndex + offset) % tabCount if newIndex < 0 { // Handle negative offsets correctly newIndex += tabCount } tabs.selectPriv(newIndex, true) tabs.m.Unlock() } func (tabs *Tabs) MoveTab(to int, relative bool) { tabs.m.Lock() tabs.moveTabPriv(to, relative) tabs.m.Unlock() } func (tabs *Tabs) moveTabPriv(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 } tabs.tabs[from], tabs.tabs[to] = tabs.tabs[to], tabs.tabs[from] tabs.curIndex = to Invalidate() } func (tabs *Tabs) PinTab() { tabs.m.Lock() defer tabs.m.Unlock() 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.moveTabPriv(pinEnd, false) } func (tabs *Tabs) UnpinTab() { tabs.m.Lock() defer tabs.m.Unlock() 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.moveTabPriv(tabs.tabs[tabs.curIndex].indexBeforePin+pinEnd-1, false) } func (tabs *Tabs) NextTab() { tabs.m.Lock() next := tabs.curIndex + 1 if next >= len(tabs.tabs) { next = 0 } tabs.selectPriv(next, true) tabs.m.Unlock() } func (tabs *Tabs) PrevTab() { tabs.m.Lock() next := tabs.curIndex - 1 if next < 0 { next = len(tabs.tabs) - 1 } tabs.selectPriv(next, true) tabs.m.Unlock() } const maxHistory = 256 func (tabs *Tabs) pushHistory(tab *Tab) { tabs.history = append(tabs.history, tab) if len(tabs.history) > maxHistory { tabs.history = tabs.history[1:] } } func (tabs *Tabs) popHistory() (int, bool) { if len(tabs.history) == 0 { return -1, false } tab := tabs.history[len(tabs.history)-1] tabs.history = tabs.history[:len(tabs.history)-1] index := -1 for i, t := range tabs.tabs { if t == tab { index = i break } } if index == -1 { return -1, false } return index, true } func (tabs *Tabs) removeHistory(tab *Tab) { var newHist []*Tab for _, item := range tabs.history { if item != tab { newHist = append(newHist, item) } } tabs.history = newHist } // TODO: Color repository func (strip *TabStrip) Draw(ctx *Context) { x := 0 strip.parent.m.Lock() for i, tab := range strip.tabs { uiConfig := strip.ui(tab.Content) if uiConfig == nil { uiConfig = config.Ui } style := uiConfig.GetStyle(config.STYLE_TAB) if strip.curIndex == i { style = uiConfig.GetStyleSelected(config.STYLE_TAB) } tabWidth := tabRuneWidth if ctx.Width()-x < tabWidth { tabWidth = ctx.Width() - x - 2 } name := tab.displayName(uiConfig.PinnedTabMarker) trunc := runewidth.Truncate(name, tabWidth, "…") x += ctx.Printf(x, 0, style, " %s ", trunc) if x >= ctx.Width() { break } } strip.parent.m.Unlock() ctx.Fill(x, 0, ctx.Width()-x, 1, ' ', config.Ui.GetStyle(config.STYLE_TAB)) } func (strip *TabStrip) Invalidate() { Invalidate() } func (strip *TabStrip) MouseEvent(localX int, localY int, event vaxis.Event) { strip.parent.m.Lock() defer strip.parent.m.Unlock() 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) } if event, ok := event.(vaxis.Mouse); ok { switch event.Button { case vaxis.MouseLeftButton: selectedTab, ok := strip.clicked(localX, localY) if !ok || selectedTab == strip.parent.curIndex { return } unfocus() strip.parent.selectPriv(selectedTab, true) refocus() case vaxis.MouseWheelDown: unfocus() index := strip.parent.curIndex + 1 if index >= len(strip.parent.tabs) { index = 0 } strip.parent.selectPriv(index, true) refocus() case vaxis.MouseWheelUp: unfocus() index := strip.parent.curIndex - 1 if index < 0 { index = len(strip.parent.tabs) - 1 } strip.parent.selectPriv(index, true) refocus() case vaxis.MouseMiddleButton: selectedTab, ok := strip.clicked(localX, localY) if !ok { return } unfocus() strip.parent.m.Unlock() strip.parent.CloseTab(selectedTab) strip.parent.m.Lock() refocus() } } } func (strip *TabStrip) clicked(mouseX int, mouseY int) (int, bool) { x := 0 for i, tab := range strip.tabs { uiConfig := strip.ui(tab.Content) if uiConfig == nil { uiConfig = config.Ui } name := tab.displayName(uiConfig.PinnedTabMarker) trunc := runewidth.Truncate(name, tabRuneWidth, "…") length := runewidth.StringWidth(trunc) + 2 if x <= mouseX && mouseX < x+length { return i, true } x += length } return 0, false } func (content *TabContent) Children() []Drawable { content.parent.m.Lock() children := make([]Drawable, len(content.tabs)) for i, tab := range content.tabs { children[i] = tab.Content } content.parent.m.Unlock() return children } func (content *TabContent) Draw(ctx *Context) { content.parent.m.Lock() if content.curIndex >= len(content.tabs) { width := ctx.Width() height := ctx.Height() ctx.Fill(0, 0, width, height, ' ', config.Ui.GetStyle(config.STYLE_TAB)) } tab := content.tabs[content.curIndex] content.parent.m.Unlock() tab.Content.Draw(ctx) } func (content *TabContent) MouseEvent(localX int, localY int, event vaxis.Event) { content.parent.m.Lock() tab := content.tabs[content.curIndex] content.parent.m.Unlock() if tabContent, ok := tab.Content.(Mouseable); ok { tabContent.MouseEvent(localX, localY, event) } } func (content *TabContent) Invalidate() { Invalidate() }