bubble.go

  1package tui
  2
  3import (
  4	"fmt"
  5	"strings"
  6
  7	tea "github.com/charmbracelet/bubbletea"
  8	"github.com/charmbracelet/lipgloss"
  9	"github.com/charmbracelet/soft/config"
 10	"github.com/charmbracelet/soft/git"
 11	"github.com/charmbracelet/soft/tui/bubbles/repo"
 12	"github.com/charmbracelet/soft/tui/bubbles/selection"
 13	"github.com/charmbracelet/soft/tui/style"
 14)
 15
 16type sessionState int
 17
 18const (
 19	startState sessionState = iota
 20	errorState
 21	loadedState
 22	quittingState
 23	quitState
 24)
 25
 26type SessionConfig struct {
 27	Width       int
 28	Height      int
 29	InitialRepo string
 30}
 31
 32type MenuEntry struct {
 33	Name   string `json:"name"`
 34	Note   string `json:"note"`
 35	Repo   string `json:"repo"`
 36	bubble *repo.Bubble
 37}
 38
 39type Bubble struct {
 40	config      *config.Config
 41	styles      *style.Styles
 42	state       sessionState
 43	error       string
 44	width       int
 45	height      int
 46	initialRepo string
 47	repoMenu    []MenuEntry
 48	repos       []*git.Repo
 49	boxes       []tea.Model
 50	activeBox   int
 51	repoSelect  *selection.Bubble
 52}
 53
 54func NewBubble(cfg *config.Config, sCfg *SessionConfig) *Bubble {
 55	b := &Bubble{
 56		config:      cfg,
 57		styles:      style.DefaultStyles(),
 58		width:       sCfg.Width,
 59		height:      sCfg.Height,
 60		repoMenu:    make([]MenuEntry, 0),
 61		boxes:       make([]tea.Model, 2),
 62		initialRepo: sCfg.InitialRepo,
 63	}
 64	b.state = startState
 65	return b
 66}
 67
 68func (b *Bubble) Init() tea.Cmd {
 69	return b.setupCmd
 70}
 71
 72func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 73	cmds := make([]tea.Cmd, 0)
 74	// Always allow state, error, info, window resize and quit messages
 75	switch msg := msg.(type) {
 76	case tea.KeyMsg:
 77		switch msg.String() {
 78		case "q", "ctrl+c":
 79			return b, tea.Quit
 80		case "tab", "shift+tab":
 81			b.activeBox = (b.activeBox + 1) % 2
 82		case "h", "left":
 83			if b.activeBox > 0 {
 84				b.activeBox--
 85			}
 86		case "l", "right":
 87			if b.activeBox < len(b.boxes)-1 {
 88				b.activeBox++
 89			}
 90		}
 91	case errMsg:
 92		b.error = msg.Error()
 93		b.state = errorState
 94		return b, nil
 95	case tea.WindowSizeMsg:
 96		b.width = msg.Width
 97		b.height = msg.Height
 98		if b.state == loadedState {
 99			for i, bx := range b.boxes {
100				m, cmd := bx.Update(msg)
101				b.boxes[i] = m
102				if cmd != nil {
103					cmds = append(cmds, cmd)
104				}
105			}
106		}
107	case selection.SelectedMsg:
108		b.activeBox = 1
109		rb := b.repoMenu[msg.Index].bubble
110		rb.GotoTop()
111		b.boxes[1] = rb
112	case selection.ActiveMsg:
113		rb := b.repoMenu[msg.Index].bubble
114		rb.GotoTop()
115		b.boxes[1] = b.repoMenu[msg.Index].bubble
116	}
117	if b.state == loadedState {
118		ab, cmd := b.boxes[b.activeBox].Update(msg)
119		b.boxes[b.activeBox] = ab
120		if cmd != nil {
121			cmds = append(cmds, cmd)
122		}
123	}
124	return b, tea.Batch(cmds...)
125}
126
127func (b *Bubble) viewForBox(i int) string {
128	isActive := i == b.activeBox
129	switch box := b.boxes[i].(type) {
130	case *selection.Bubble:
131		// Menu
132		var s lipgloss.Style
133		s = b.styles.Menu
134		if isActive {
135			s = s.Copy().BorderForeground(b.styles.ActiveBorderColor)
136		}
137		return s.Render(box.View())
138	case *repo.Bubble:
139		// Repo details
140		box.Active = isActive
141		return box.View()
142	default:
143		panic(fmt.Sprintf("unknown box type %T", box))
144	}
145}
146
147func (b Bubble) headerView() string {
148	w := b.width - b.styles.App.GetHorizontalFrameSize()
149	name := ""
150	if b.config != nil {
151		name = b.config.Name
152	}
153	return b.styles.Header.Copy().Width(w).Render(name)
154}
155
156func (b Bubble) footerView() string {
157	w := &strings.Builder{}
158	var h []helpEntry
159	switch b.state {
160	case errorState:
161		h = []helpEntry{{"q", "quit"}}
162	default:
163		h = []helpEntry{
164			{"tab", "section"},
165			{"↑/↓", "navigate"},
166			{"q", "quit"},
167		}
168		if _, ok := b.boxes[b.activeBox].(*repo.Bubble); ok {
169			h = append(h[:2], helpEntry{"f/b", "pgup/pgdown"}, h[2])
170		}
171	}
172	for i, v := range h {
173		fmt.Fprint(w, v.Render(b.styles))
174		if i != len(h)-1 {
175			fmt.Fprint(w, b.styles.HelpDivider)
176		}
177	}
178	return b.styles.Footer.Copy().Width(b.width).Render(w.String())
179}
180
181func (b Bubble) errorView() string {
182	s := b.styles
183	str := lipgloss.JoinHorizontal(
184		lipgloss.Top,
185		s.ErrorTitle.Render("Bummer"),
186		s.ErrorBody.Render(b.error),
187	)
188	h := b.height -
189		s.App.GetVerticalFrameSize() -
190		lipgloss.Height(b.headerView()) -
191		lipgloss.Height(b.footerView()) -
192		s.RepoBody.GetVerticalFrameSize() +
193		3 // TODO: this is repo header height -- get it dynamically
194	return s.Error.Copy().Height(h).Render(str)
195}
196
197func (b Bubble) View() string {
198	s := strings.Builder{}
199	s.WriteString(b.headerView())
200	s.WriteRune('\n')
201	switch b.state {
202	case loadedState:
203		lb := b.viewForBox(0)
204		rb := b.viewForBox(1)
205		s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, lb, rb))
206	case errorState:
207		s.WriteString(b.errorView())
208	}
209	s.WriteRune('\n')
210	s.WriteString(b.footerView())
211	return b.styles.App.Render(s.String())
212}
213
214type helpEntry struct {
215	key string
216	val string
217}
218
219func (h helpEntry) Render(s *style.Styles) string {
220	return fmt.Sprintf("%s %s", s.HelpKey.Render(h.key), s.HelpValue.Render(h.val))
221}