bubble.go

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