package ui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
)
// tabSection is one named group of lines in a tabsModal.
type tabSection struct {
title string
lines []string
//: how many entries in this section want attention, for the tab bar
flagged int
//: a short note shown under the tab bar
note string
}
// tabsModal shows several related listings one at a time.
//
// Warnings are the case this exists for: one type is fine as a paragraph, but
// three types stacked into one scroll makes the third look like an afterthought
// and buries whichever one matters. A tab each keeps every type a first-class
// thing, and the tab bar says how many there are before you start reading.
type tabsModal struct {
title string
tabs []tabSection
current int
scroll int
offset int
searching bool
query string
matchAt int
matches []int
searchErr string
height int
}
func newTabs(title string, tabs []tabSection) *tabsModal {
return &tabsModal{title: title, tabs: tabs}
}
func (t *tabsModal) tab() *tabSection {
if t.current < 0 || t.current >= len(t.tabs) {
return nil
}
return &t.tabs[t.current]
}
func (t *tabsModal) Update(msg tea.Msg) (modal, tea.Cmd) {
switch msg := msg.(type) {
case spinnerTickMsg:
return t, nil
case tea.KeyMsg:
if t.searching {
return t.updateSearch(msg)
}
switch msg.String() {
case "q", "esc", "ctrl+c":
return nil, nil
case "tab", "l", "right":
t.next(1)
case "shift+tab", "h", "left":
t.next(-1)
case "j", "down":
t.scroll++
case "k", "up":
t.scroll--
case "ctrl+d", "pgdown":
t.scroll += 10
case "ctrl+u", "pgup":
t.scroll -= 10
case "g", "home":
t.scroll = 0
case "G", "end":
t.scroll = 1 << 30
case "/":
t.searching = true
t.query = ""
t.searchErr = ""
case "n":
t.nextMatch(1)
case "N":
t.nextMatch(-1)
default:
// a digit jumps straight to a tab
if len(msg.String()) == 1 && msg.String()[0] >= '1' && msg.String()[0] <= '9' {
index := int(msg.String()[0] - '1')
if index < len(t.tabs) {
t.current = index
t.scroll, t.offset = 0, 0
t.recompute()
}
}
}
}
return t, nil
}
// next switches tabs, wrapping. Each tab starts at its own top.
func (t *tabsModal) next(step int) {
if len(t.tabs) == 0 {
return
}
t.current = (t.current + step + len(t.tabs)) % len(t.tabs)
t.scroll, t.offset = 0, 0
t.recompute()
}
func (t *tabsModal) updateSearch(msg tea.KeyMsg) (modal, tea.Cmd) {
switch msg.Type {
case tea.KeyEsc:
t.searching = false
t.query = ""
t.searchErr = ""
return t, nil
case tea.KeyEnter:
t.searching = false
if t.query != "" && len(t.matches) == 0 {
t.searchErr = "no match"
}
return t, nil
case tea.KeyBackspace:
if t.query != "" {
runes := []rune(t.query)
t.query = string(runes[:len(runes)-1])
}
t.recompute()
return t, nil
case tea.KeyRunes, tea.KeySpace:
t.query += string(msg.Runes)
t.recompute()
return t, nil
}
return t, nil
}
// recompute marks matches in the current tab only; searching across tabs would
// need a way to say which tab a hit is in, and the tab bar already shows that.
func (t *tabsModal) recompute() {
t.searchErr = ""
t.matches = t.matches[:0]
section := t.tab()
if section == nil || t.query == "" {
return
}
needle := strings.ToLower(t.query)
for i, line := range section.lines {
if strings.Contains(strings.ToLower(ansi.Strip(line)), needle) {
t.matches = append(t.matches, i)
}
}
t.matchAt = 0
if len(t.matches) > 0 {
t.scroll = t.matches[0]
}
}
func (t *tabsModal) nextMatch(step int) {
if len(t.matches) == 0 {
return
}
t.matchAt = (t.matchAt + step + len(t.matches)) % len(t.matches)
t.scroll = t.matches[t.matchAt]
}
func (t *tabsModal) View(width, height int) string {
inner := width - 8
if inner < 24 {
inner = 24
}
t.height = height
rows := max(3, height-11)
var b strings.Builder
b.WriteString(Title.Render(t.title))
if len(t.tabs) > 1 {
b.WriteString("\n\n")
b.WriteString(t.tabBar())
}
section := t.tab()
b.WriteString("\n")
if section == nil {
b.WriteString("\n" + Muted.Render("nothing to show here"))
return place(Box.Render(b.String()), width, height)
}
if section.note != "" {
b.WriteString("\n" + Muted.Render(ansi.Truncate(section.note, inner, "…")))
}
b.WriteString("\n\n")
total := len(section.lines)
if t.scroll > total-rows {
t.scroll = max(0, total-rows)
}
if t.scroll < 0 {
t.scroll = 0
}
end := min(total, t.scroll+rows)
for _, line := range section.lines[t.scroll:end] {
b.WriteString(ansi.Truncate(line, inner, "…") + "\n")
}
for i := end - t.scroll; i < rows; i++ {
b.WriteString("\n")
}
b.WriteString(t.footer(inner, total))
return place(Box.Render(b.String()), width, height)
}
// tabBar renders the tabs, marking the current one and any that are flagged.
func (t *tabsModal) tabBar() string {
parts := make([]string, 0, len(t.tabs))
for i, tab := range t.tabs {
label := tab.title
if tab.flagged > 0 {
label += fmt.Sprintf(" %d", tab.flagged)
}
if i == t.current {
parts = append(parts, Selected.Render(" "+label+" "))
continue
}
if tab.flagged > 0 {
parts = append(parts, Warn.Render(" "+label+" "))
continue
}
parts = append(parts, Muted.Render(" "+label+" "))
}
return strings.Join(parts, Muted.Render("│"))
}
func (t *tabsModal) footer(inner int, total int) string {
pos := fmt.Sprintf("[%d/%d]", t.scroll+1, max(1, total))
hint := "[j/k] scroll • [/] search • [esc] close"
if len(t.tabs) > 1 {
hint = "[tab] switch • " + hint
}
if t.searching || t.query != "" {
cursor := ""
if t.searching {
cursor = Accent.Render("_")
}
hint = "/" + t.query + cursor
if len(t.matches) > 0 {
hint = "n/N " + hint + Muted.Render(
fmt.Sprintf(" %d/%d", t.matchAt+1, len(t.matches)))
} else if t.searchErr != "" {
hint += Warn.Render(" " + t.searchErr)
}
}
gap := inner - ansi.StringWidth(hint) - len(pos)
if gap < 1 {
gap = 1
}
return "\n" + Desc.Render(hint) + strings.Repeat(" ", gap) + Muted.Render(pos)
}