bubble.go

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