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