code.go

  1package code
  2
  3import (
  4	"fmt"
  5	"strings"
  6	"sync"
  7
  8	"github.com/alecthomas/chroma/lexers"
  9	tea "github.com/charmbracelet/bubbletea"
 10	"github.com/charmbracelet/glamour"
 11	gansi "github.com/charmbracelet/glamour/ansi"
 12	"github.com/charmbracelet/lipgloss"
 13	"github.com/charmbracelet/soft-serve/ui/common"
 14	vp "github.com/charmbracelet/soft-serve/ui/components/viewport"
 15	"github.com/muesli/reflow/wrap"
 16	"github.com/muesli/termenv"
 17)
 18
 19var (
 20	lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
 21	lineBarStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
 22)
 23
 24// Code is a code snippet.
 25type Code struct {
 26	*vp.Viewport
 27	common         common.Common
 28	content        string
 29	extension      string
 30	renderContext  gansi.RenderContext
 31	renderMutex    sync.Mutex
 32	styleConfig    gansi.StyleConfig
 33	showLineNumber bool
 34
 35	NoContentStyle lipgloss.Style
 36	LineDigitStyle lipgloss.Style
 37	LineBarStyle   lipgloss.Style
 38}
 39
 40// New returns a new Code.
 41func New(c common.Common, content, extension string) *Code {
 42	r := &Code{
 43		common:         c,
 44		content:        content,
 45		extension:      extension,
 46		Viewport:       vp.New(c),
 47		NoContentStyle: c.Styles.CodeNoContent.Copy(),
 48		LineDigitStyle: lineDigitStyle,
 49		LineBarStyle:   lineBarStyle,
 50	}
 51	st := common.StyleConfig()
 52	r.styleConfig = st
 53	r.renderContext = gansi.NewRenderContext(gansi.Options{
 54		ColorProfile: termenv.TrueColor,
 55		Styles:       st,
 56	})
 57	r.SetSize(c.Width, c.Height)
 58	return r
 59}
 60
 61// SetShowLineNumber sets whether to show line numbers.
 62func (r *Code) SetShowLineNumber(show bool) {
 63	r.showLineNumber = show
 64}
 65
 66// SetSize implements common.Component.
 67func (r *Code) SetSize(width, height int) {
 68	r.common.SetSize(width, height)
 69	r.Viewport.SetSize(width, height)
 70}
 71
 72// SetContent sets the content of the Code.
 73func (r *Code) SetContent(c, ext string) tea.Cmd {
 74	r.content = c
 75	r.extension = ext
 76	return r.Init()
 77}
 78
 79// Init implements tea.Model.
 80func (r *Code) Init() tea.Cmd {
 81	w := r.common.Width
 82	c := r.content
 83	if c == "" {
 84		r.Viewport.Model.SetContent(r.NoContentStyle.String())
 85		return nil
 86	}
 87	f, err := r.renderFile(r.extension, c, w)
 88	if err != nil {
 89		return common.ErrorCmd(err)
 90	}
 91	r.Viewport.Model.SetContent(f)
 92	return nil
 93}
 94
 95// Update implements tea.Model.
 96func (r *Code) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 97	cmds := make([]tea.Cmd, 0)
 98	switch msg.(type) {
 99	case tea.WindowSizeMsg:
100		// Recalculate content width and line wrap.
101		cmds = append(cmds, r.Init())
102	}
103	v, cmd := r.Viewport.Update(msg)
104	r.Viewport = v.(*vp.Viewport)
105	if cmd != nil {
106		cmds = append(cmds, cmd)
107	}
108	return r, tea.Batch(cmds...)
109}
110
111// View implements tea.View.
112func (r *Code) View() string {
113	return r.Viewport.View()
114}
115
116// GotoTop moves the viewport to the top of the log.
117func (r *Code) GotoTop() {
118	r.Viewport.GotoTop()
119}
120
121// GotoBottom moves the viewport to the bottom of the log.
122func (r *Code) GotoBottom() {
123	r.Viewport.GotoBottom()
124}
125
126// HalfViewDown moves the viewport down by half the viewport height.
127func (r *Code) HalfViewDown() {
128	r.Viewport.HalfViewDown()
129}
130
131// HalfViewUp moves the viewport up by half the viewport height.
132func (r *Code) HalfViewUp() {
133	r.Viewport.HalfViewUp()
134}
135
136// ViewUp moves the viewport up by a page.
137func (r *Code) ViewUp() []string {
138	return r.Viewport.ViewUp()
139}
140
141// ViewDown moves the viewport down by a page.
142func (r *Code) ViewDown() []string {
143	return r.Viewport.ViewDown()
144}
145
146// LineUp moves the viewport up by the given number of lines.
147func (r *Code) LineUp(n int) []string {
148	return r.Viewport.LineUp(n)
149}
150
151// LineDown moves the viewport down by the given number of lines.
152func (r *Code) LineDown(n int) []string {
153	return r.Viewport.LineDown(n)
154}
155
156// ScrollPercent returns the viewport's scroll percentage.
157func (r *Code) ScrollPercent() float64 {
158	return r.Viewport.ScrollPercent()
159}
160
161func (r *Code) glamourize(w int, md string) (string, error) {
162	r.renderMutex.Lock()
163	defer r.renderMutex.Unlock()
164	// This fixes a bug with markdown text wrapping being off by one.
165	if w > 0 {
166		w--
167	}
168	tr, err := glamour.NewTermRenderer(
169		glamour.WithStyles(r.styleConfig),
170		glamour.WithWordWrap(w),
171	)
172
173	if err != nil {
174		return "", err
175	}
176	mdt, err := tr.Render(md)
177	if err != nil {
178		return "", err
179	}
180	return mdt, nil
181}
182
183func (r *Code) renderFile(path, content string, width int) (string, error) {
184	lexer := lexers.Fallback
185	if path == "" {
186		lexer = lexers.Analyse(content)
187	} else {
188		lexer = lexers.Match(path)
189	}
190	lang := ""
191	if lexer != nil && lexer.Config() != nil {
192		lang = lexer.Config().Name
193	}
194	if lang == "markdown" {
195		md, err := r.glamourize(width, content)
196		if err != nil {
197			return "", err
198		}
199		return md, nil
200	}
201	formatter := &gansi.CodeBlockElement{
202		Code:     content,
203		Language: lang,
204	}
205	s := strings.Builder{}
206	rc := r.renderContext
207	if r.showLineNumber {
208		st := common.StyleConfig()
209		m := uint(0)
210		st.CodeBlock.Margin = &m
211		rc = gansi.NewRenderContext(gansi.Options{
212			ColorProfile: termenv.TrueColor,
213			Styles:       st,
214		})
215	}
216	err := formatter.Render(&s, rc)
217	if err != nil {
218		return "", err
219	}
220	c := s.String()
221	if r.showLineNumber {
222		c = withLineNumber(c)
223	}
224	// FIXME: this is a hack to reset formatting at the end of every line.
225	c = wrap.String(c, width)
226	f := strings.Split(c, "\n")
227	for i, l := range f {
228		f[i] = l + "\x1b[0m"
229	}
230	return strings.Join(f, "\n"), nil
231}
232
233func withLineNumber(s string) string {
234	lines := strings.Split(s, "\n")
235	// NB: len() is not a particularly safe way to count string width (because
236	// it's counting bytes instead of runes) but in this case it's okay
237	// because we're only dealing with digits, which are one byte each.
238	mll := len(fmt.Sprintf("%d", len(lines)))
239	for i, l := range lines {
240		digit := fmt.Sprintf("%*d", mll, i+1)
241		bar := "│"
242		digit = lineDigitStyle.Render(digit)
243		bar = lineBarStyle.Render(bar)
244		if i < len(lines)-1 || len(l) != 0 {
245			// If the final line was a newline we'll get an empty string for
246			// the final line, so drop the newline altogether.
247			lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
248		}
249	}
250	return strings.Join(lines, "\n")
251}