bubble.go

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