bubble.go

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