git/orchard/main
tui/internal/ui/tabs_modal.go
263 lines6 KB last change raw · history
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)
}