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