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