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