bubble.go

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