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