code.go

  1package code
  2
  3import (
  4	"strings"
  5	"sync"
  6
  7	"github.com/alecthomas/chroma/lexers"
  8	tea "github.com/charmbracelet/bubbletea"
  9	"github.com/charmbracelet/glamour"
 10	gansi "github.com/charmbracelet/glamour/ansi"
 11	"github.com/charmbracelet/lipgloss"
 12	"github.com/charmbracelet/soft-serve/ui/common"
 13	vp "github.com/charmbracelet/soft-serve/ui/components/viewport"
 14	"github.com/muesli/reflow/wrap"
 15	"github.com/muesli/termenv"
 16)
 17
 18// Code is a code snippet.
 19type Code struct {
 20	*vp.Viewport
 21	common         common.Common
 22	content        string
 23	extension      string
 24	renderContext  gansi.RenderContext
 25	renderMutex    sync.Mutex
 26	styleConfig    gansi.StyleConfig
 27	NoContentStyle lipgloss.Style
 28}
 29
 30// New returns a new Code.
 31func New(c common.Common, content, extension string) *Code {
 32	r := &Code{
 33		common:         c,
 34		content:        content,
 35		extension:      extension,
 36		Viewport:       vp.New(c),
 37		NoContentStyle: c.Styles.CodeNoContent.Copy(),
 38	}
 39	st := common.StyleConfig()
 40	r.styleConfig = st
 41	r.renderContext = gansi.NewRenderContext(gansi.Options{
 42		ColorProfile: termenv.TrueColor,
 43		Styles:       st,
 44	})
 45	r.SetSize(c.Width, c.Height)
 46	return r
 47}
 48
 49// SetSize implements common.Component.
 50func (r *Code) SetSize(width, height int) {
 51	r.common.SetSize(width, height)
 52	r.Viewport.SetSize(width, height)
 53}
 54
 55// SetContent sets the content of the Code.
 56func (r *Code) SetContent(c, ext string) tea.Cmd {
 57	r.content = c
 58	r.extension = ext
 59	return r.Init()
 60}
 61
 62// Init implements tea.Model.
 63func (r *Code) Init() tea.Cmd {
 64	w := r.common.Width
 65	c := r.content
 66	if c == "" {
 67		c = r.NoContentStyle.String()
 68	}
 69	f, err := r.renderFile(r.extension, c, w)
 70	if err != nil {
 71		return common.ErrorCmd(err)
 72	}
 73	// FIXME: this is a hack to reset formatting at the end of every line.
 74	c = wrap.String(f, w)
 75	s := strings.Split(c, "\n")
 76	for i, l := range s {
 77		s[i] = l + "\x1b[0m"
 78	}
 79	r.Viewport.Model.SetContent(strings.Join(s, "\n"))
 80	return nil
 81}
 82
 83// Update implements tea.Model.
 84func (r *Code) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 85	cmds := make([]tea.Cmd, 0)
 86	switch msg.(type) {
 87	case tea.WindowSizeMsg:
 88		// Recalculate content width and line wrap.
 89		cmds = append(cmds, r.Init())
 90	}
 91	v, cmd := r.Viewport.Update(msg)
 92	r.Viewport = v.(*vp.Viewport)
 93	if cmd != nil {
 94		cmds = append(cmds, cmd)
 95	}
 96	return r, tea.Batch(cmds...)
 97}
 98
 99// View implements tea.View.
100func (r *Code) View() string {
101	return r.Viewport.View()
102}
103
104// GotoTop moves the viewport to the top of the log.
105func (r *Code) GotoTop() {
106	r.Viewport.GotoTop()
107}
108
109// GotoBottom moves the viewport to the bottom of the log.
110func (r *Code) GotoBottom() {
111	r.Viewport.GotoBottom()
112}
113
114// HalfViewDown moves the viewport down by half the viewport height.
115func (r *Code) HalfViewDown() {
116	r.Viewport.HalfViewDown()
117}
118
119// HalfViewUp moves the viewport up by half the viewport height.
120func (r *Code) HalfViewUp() {
121	r.Viewport.HalfViewUp()
122}
123
124// ViewUp moves the viewport up by a page.
125func (r *Code) ViewUp() []string {
126	return r.Viewport.ViewUp()
127}
128
129// ViewDown moves the viewport down by a page.
130func (r *Code) ViewDown() []string {
131	return r.Viewport.ViewDown()
132}
133
134// LineUp moves the viewport up by the given number of lines.
135func (r *Code) LineUp(n int) []string {
136	return r.Viewport.LineUp(n)
137}
138
139// LineDown moves the viewport down by the given number of lines.
140func (r *Code) LineDown(n int) []string {
141	return r.Viewport.LineDown(n)
142}
143
144// ScrollPercent returns the viewport's scroll percentage.
145func (r *Code) ScrollPercent() float64 {
146	return r.Viewport.ScrollPercent()
147}
148
149func (r *Code) glamourize(w int, md string) (string, error) {
150	r.renderMutex.Lock()
151	defer r.renderMutex.Unlock()
152	if w > 120 {
153		w = 120
154	}
155	tr, err := glamour.NewTermRenderer(
156		glamour.WithStyles(r.styleConfig),
157		glamour.WithWordWrap(w),
158	)
159
160	if err != nil {
161		return "", err
162	}
163	mdt, err := tr.Render(md)
164	if err != nil {
165		return "", err
166	}
167	return mdt, nil
168}
169
170func (r *Code) renderFile(path, content string, width int) (string, error) {
171	lexer := lexers.Fallback
172	if path == "" {
173		lexer = lexers.Analyse(content)
174	} else {
175		lexer = lexers.Match(path)
176	}
177	lang := ""
178	if lexer != nil && lexer.Config() != nil {
179		lang = lexer.Config().Name
180	}
181	if lang == "markdown" {
182		md, err := r.glamourize(width, content)
183		if err != nil {
184			return "", err
185		}
186		return md, nil
187	}
188	formatter := &gansi.CodeBlockElement{
189		Code:     content,
190		Language: lang,
191	}
192	s := strings.Builder{}
193	err := formatter.Render(&s, r.renderContext)
194	if err != nil {
195		return "", err
196	}
197	return s.String(), nil
198}