bubble.go

  1package repo
  2
  3import (
  4	"bytes"
  5	"fmt"
  6	"smoothie/git"
  7	"strconv"
  8	"text/template"
  9
 10	"github.com/charmbracelet/bubbles/viewport"
 11	tea "github.com/charmbracelet/bubbletea"
 12	"github.com/charmbracelet/glamour"
 13	"github.com/charmbracelet/lipgloss"
 14	"github.com/muesli/reflow/wordwrap"
 15	"github.com/muesli/reflow/wrap"
 16)
 17
 18const glamourMaxWidth = 120
 19
 20type ErrMsg struct {
 21	Error error
 22}
 23
 24type Bubble struct {
 25	templateObject    interface{}
 26	repoSource        *git.RepoSource
 27	name              string
 28	repo              *git.Repo
 29	readmeViewport    *ViewportBubble
 30	readme            string
 31	height            int
 32	heightMargin      int
 33	width             int
 34	widthMargin       int
 35	Active            bool
 36	TitleStyle        lipgloss.Style
 37	NoteStyle         lipgloss.Style
 38	BodyStyle         lipgloss.Style
 39	ActiveBorderColor lipgloss.Color
 40
 41	// XXX: ideally, we get these from the parent as a pointer. Currently, we
 42	// can't add a *tui.Config because it's an illegal import cycle. One
 43	// solution would be to (rename and) move this Bubble into the parent
 44	// package.
 45	Host string
 46	Port int64
 47}
 48
 49func NewBubble(rs *git.RepoSource, name string, width, wm, height, hm int, tmp interface{}) *Bubble {
 50	b := &Bubble{
 51		templateObject: tmp,
 52		repoSource:     rs,
 53		name:           name,
 54		heightMargin:   hm,
 55		widthMargin:    wm,
 56		readmeViewport: &ViewportBubble{
 57			Viewport: &viewport.Model{},
 58		},
 59	}
 60	b.SetSize(width, height)
 61	return b
 62}
 63
 64func (b *Bubble) Init() tea.Cmd {
 65	return b.setupCmd
 66}
 67
 68func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 69	var cmds []tea.Cmd
 70	switch msg := msg.(type) {
 71	case tea.WindowSizeMsg:
 72		b.SetSize(msg.Width, msg.Height)
 73		// XXX: if we find that longer readmes take more than a few
 74		// milliseconds to render we may need to move Glamour rendering into a
 75		// command.
 76		md, err := b.glamourize(b.readme)
 77		if err != nil {
 78			return b, nil
 79		}
 80		b.readmeViewport.Viewport.SetContent(md)
 81	}
 82	rv, cmd := b.readmeViewport.Update(msg)
 83	b.readmeViewport = rv.(*ViewportBubble)
 84	cmds = append(cmds, cmd)
 85	return b, tea.Batch(cmds...)
 86}
 87
 88func (b *Bubble) SetSize(w, h int) {
 89	b.width = w
 90	b.height = h
 91	b.readmeViewport.Viewport.Width = w - b.widthMargin
 92	b.readmeViewport.Viewport.Height = h - b.heightMargin
 93}
 94
 95func (b *Bubble) GotoTop() {
 96	b.readmeViewport.Viewport.GotoTop()
 97}
 98
 99func (b Bubble) headerView() string {
100	ts := b.TitleStyle
101	ns := b.NoteStyle
102	if b.Active {
103		ts = ts.Copy().BorderForeground(b.ActiveBorderColor)
104		ns = ns.Copy().BorderForeground(b.ActiveBorderColor)
105	}
106	n := b.name
107	if n == "config" {
108		n = "Home"
109	}
110	title := ts.Render(n)
111	note := ns.Width(b.width - b.widthMargin - lipgloss.Width(title)).
112		Render(b.sshAddress())
113	return lipgloss.JoinHorizontal(lipgloss.Top, title, note)
114}
115
116func (b *Bubble) View() string {
117	header := b.headerView()
118	bs := b.BodyStyle.Copy()
119	if b.Active {
120		bs = bs.BorderForeground(b.ActiveBorderColor)
121	}
122	body := bs.
123		Width(b.width - b.widthMargin - b.BodyStyle.GetVerticalFrameSize()).
124		Height(b.height - b.heightMargin - lipgloss.Height(header)).
125		Render(b.readmeViewport.View())
126	return header + body
127}
128
129func (b Bubble) sshAddress() string {
130	p := ":" + strconv.Itoa(int(b.Port))
131	if p == ":22" {
132		p = ""
133	}
134	return fmt.Sprintf("ssh://%s%s/%s", b.Host, p, b.name)
135}
136
137func (b *Bubble) setupCmd() tea.Msg {
138	r, err := b.repoSource.GetRepo(b.name)
139	if err == git.ErrMissingRepo {
140		return nil
141	}
142	if err != nil {
143		return ErrMsg{err}
144	}
145	md := r.Readme
146	if b.templateObject != nil {
147		md, err = b.templatize(md)
148		if err != nil {
149			return ErrMsg{err}
150		}
151	}
152	b.readme = md
153	md, err = b.glamourize(md)
154	if err != nil {
155		return ErrMsg{err}
156	}
157	b.readmeViewport.Viewport.SetContent(md)
158	b.GotoTop()
159	return nil
160}
161
162func (b *Bubble) templatize(mdt string) (string, error) {
163	t, err := template.New("readme").Parse(mdt)
164	if err != nil {
165		return "", err
166	}
167	buf := &bytes.Buffer{}
168	err = t.Execute(buf, b.templateObject)
169	if err != nil {
170		return "", err
171	}
172	return buf.String(), nil
173}
174
175func (b *Bubble) glamourize(md string) (string, error) {
176	// TODO: read gaps in appropriate style to remove the magic number below.
177	w := b.width - b.widthMargin - 4
178	if w > glamourMaxWidth {
179		w = glamourMaxWidth
180	}
181	tr, err := glamour.NewTermRenderer(
182		glamour.WithStandardStyle("dark"),
183		glamour.WithWordWrap(w),
184	)
185
186	if err != nil {
187		return "", err
188	}
189	mdt, err := tr.Render(md)
190	if err != nil {
191		return "", err
192	}
193	// Enforce a maximum width for cases when glamour lines run long.
194	//
195	// TODO: This should utlimately be implemented as a Glamour option.
196	mdt = wrap.String(wordwrap.String((mdt), w), w)
197	return mdt, nil
198}