bubble.go

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