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) }