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	tr, err := glamour.NewTermRenderer(
165		glamour.WithStyles(r.styleConfig),
166		glamour.WithWordWrap(w),
167	)
168
169	if err != nil {
170		return "", err
171	}
172	mdt, err := tr.Render(md)
173	if err != nil {
174		return "", err
175	}
176	return mdt, nil
177}
178
179func (r *Code) renderFile(path, content string, width int) (string, error) {
180	lexer := lexers.Fallback
181	if path == "" {
182		lexer = lexers.Analyse(content)
183	} else {
184		lexer = lexers.Match(path)
185	}
186	lang := ""
187	if lexer != nil && lexer.Config() != nil {
188		lang = lexer.Config().Name
189	}
190	if lang == "markdown" {
191		md, err := r.glamourize(width, content)
192		if err != nil {
193			return "", err
194		}
195		return md, nil
196	}
197	formatter := &gansi.CodeBlockElement{
198		Code:     content,
199		Language: lang,
200	}
201	s := strings.Builder{}
202	rc := r.renderContext
203	if r.showLineNumber {
204		st := common.StyleConfig()
205		var m uint = 0
206		st.CodeBlock.Margin = &m
207		rc = gansi.NewRenderContext(gansi.Options{
208			ColorProfile: termenv.TrueColor,
209			Styles:       st,
210		})
211	}
212	err := formatter.Render(&s, rc)
213	if err != nil {
214		return "", err
215	}
216	c := s.String()
217	if r.showLineNumber {
218		c = withLineNumber(c)
219	}
220	// FIXME: this is a hack to reset formatting at the end of every line.
221	c = wrap.String(c, width)
222	f := strings.Split(c, "\n")
223	for i, l := range f {
224		f[i] = l + "\x1b[0m"
225	}
226	return strings.Join(f, "\n"), nil
227}
228
229func withLineNumber(s string) string {
230	lines := strings.Split(s, "\n")
231	// NB: len() is not a particularly safe way to count string width (because
232	// it's counting bytes instead of runes) but in this case it's okay
233	// because we're only dealing with digits, which are one byte each.
234	mll := len(fmt.Sprintf("%d", len(lines)))
235	for i, l := range lines {
236		digit := fmt.Sprintf("%*d", mll, i+1)
237		bar := "│"
238		digit = lineDigitStyle.Render(digit)
239		bar = lineBarStyle.Render(bar)
240		if i < len(lines)-1 || len(l) != 0 {
241			// If the final line was a newline we'll get an empty string for
242			// the final line, so drop the newline altogether.
243			lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
244		}
245	}
246	return strings.Join(lines, "\n")
247}