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	"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	InitialRepo string
 46}
 47
 48type Bubble struct {
 49	config        *Config
 50	styles        *style.Styles
 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}
 64
 65func NewBubble(cfg *Config, sCfg *SessionConfig) *Bubble {
 66	b := &Bubble{
 67		config:      cfg,
 68		styles:      style.DefaultStyles(),
 69		width:       sCfg.Width,
 70		height:      sCfg.Height,
 71		repoSource:  cfg.RepoSource,
 72		repoMenu:    make([]MenuEntry, 0),
 73		boxes:       make([]tea.Model, 2),
 74		initialRepo: sCfg.InitialRepo,
 75	}
 76	b.state = startState
 77	return b
 78}
 79
 80func (b *Bubble) Init() tea.Cmd {
 81	return b.setupCmd
 82}
 83
 84func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 85	cmds := make([]tea.Cmd, 0)
 86	// Always allow state, error, info, window resize and quit messages
 87	switch msg := msg.(type) {
 88	case tea.KeyMsg:
 89		switch msg.String() {
 90		case "q", "ctrl+c":
 91			return b, tea.Quit
 92		case "tab", "shift+tab":
 93			b.activeBox = (b.activeBox + 1) % 2
 94		case "h", "left":
 95			if b.activeBox > 0 {
 96				b.activeBox--
 97			}
 98		case "l", "right":
 99			if b.activeBox < len(b.boxes)-1 {
100				b.activeBox++
101			}
102		}
103	case errMsg:
104		b.error = msg.Error()
105		b.state = errorState
106		return b, nil
107	case tea.WindowSizeMsg:
108		b.width = msg.Width
109		b.height = msg.Height
110		if b.state == loadedState {
111			for i, bx := range b.boxes {
112				m, cmd := bx.Update(msg)
113				b.boxes[i] = m
114				if cmd != nil {
115					cmds = append(cmds, cmd)
116				}
117			}
118		}
119	case selection.SelectedMsg:
120		b.activeBox = 1
121		rb := b.repoMenu[msg.Index].bubble
122		rb.GotoTop()
123		b.boxes[1] = rb
124	case selection.ActiveMsg:
125		rb := b.repoMenu[msg.Index].bubble
126		rb.GotoTop()
127		b.boxes[1] = b.repoMenu[msg.Index].bubble
128	}
129	if b.state == loadedState {
130		ab, cmd := b.boxes[b.activeBox].Update(msg)
131		b.boxes[b.activeBox] = ab
132		if cmd != nil {
133			cmds = append(cmds, cmd)
134		}
135	}
136	return b, tea.Batch(cmds...)
137}
138
139func (b *Bubble) viewForBox(i int) string {
140	isActive := i == b.activeBox
141	switch box := b.boxes[i].(type) {
142	case *selection.Bubble:
143		var s lipgloss.Style
144		s = b.styles.Menu
145		if isActive {
146			s = s.Copy().BorderForeground(b.styles.ActiveBorderColor)
147		}
148		return s.Render(box.View())
149	case *repo.Bubble:
150		box.Active = isActive
151		return box.View()
152	default:
153		panic(fmt.Sprintf("unknown box type %T", box))
154	}
155}
156
157func (b Bubble) headerView() string {
158	w := b.width - b.styles.App.GetHorizontalFrameSize()
159	return b.styles.Header.Copy().Width(w).Render(b.config.Name)
160}
161
162func (b Bubble) footerView() string {
163	w := &strings.Builder{}
164	var h []helpEntry
165	switch b.state {
166	case errorState:
167		h = []helpEntry{{"q", "quit"}}
168	default:
169		h = []helpEntry{
170			{"tab", "section"},
171			{"↑/↓", "navigate"},
172			{"q", "quit"},
173		}
174		if _, ok := b.boxes[b.activeBox].(*repo.Bubble); ok {
175			h = append(h[:2], helpEntry{"f/b", "pgup/pgdown"}, h[2])
176		}
177	}
178	for i, v := range h {
179		fmt.Fprint(w, v.Render(b.styles))
180		if i != len(h)-1 {
181			fmt.Fprint(w, b.styles.HelpDivider)
182		}
183	}
184	return b.styles.Footer.Copy().Width(b.width).Render(w.String())
185}
186
187func (b Bubble) errorView() string {
188	s := b.styles
189	str := lipgloss.JoinHorizontal(
190		lipgloss.Top,
191		s.ErrorTitle.Render("Bummer"),
192		s.ErrorBody.Render(b.error),
193	)
194	h := b.height -
195		s.App.GetVerticalFrameSize() -
196		lipgloss.Height(b.headerView()) -
197		lipgloss.Height(b.footerView()) -
198		s.RepoBody.GetVerticalFrameSize() +
199		3 // TODO: this is repo header height -- get it dynamically
200	return s.Error.Copy().Height(h).Render(str)
201}
202
203func (b Bubble) View() string {
204	s := strings.Builder{}
205	s.WriteString(b.headerView())
206	s.WriteRune('\n')
207	switch b.state {
208	case loadedState:
209		lb := b.viewForBox(0)
210		rb := b.viewForBox(1)
211		s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, lb, rb))
212	case errorState:
213		s.WriteString(b.errorView())
214	}
215	s.WriteRune('\n')
216	s.WriteString(b.footerView())
217	return b.styles.App.Render(s.String())
218}
219
220type helpEntry struct {
221	key string
222	val string
223}
224
225func (h helpEntry) Render(s *style.Styles) string {
226	return fmt.Sprintf("%s %s", s.HelpKey.Render(h.key), s.HelpValue.Render(h.val))
227}