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/server/ui/common"
 14	vp "github.com/charmbracelet/soft-serve/server/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.NoContent.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	if w > 120 {
168		w = 120
169	}
170	tr, err := glamour.NewTermRenderer(
171		glamour.WithStyles(r.styleConfig),
172		glamour.WithWordWrap(w),
173	)
174
175	if err != nil {
176		return "", err
177	}
178	mdt, err := tr.Render(md)
179	if err != nil {
180		return "", err
181	}
182	return mdt, nil
183}
184
185func (r *Code) renderFile(path, content string, width int) (string, error) {
186	// FIXME chroma & glamour might break wrapping when using tabs since tab
187	// width depends on the terminal. This is a workaround to replace tabs with
188	// 4-spaces.
189	content = strings.ReplaceAll(content, "\t", strings.Repeat(" ", tabWidth))
190	lexer := lexers.Match(path)
191	if path == "" {
192		lexer = lexers.Analyse(content)
193	}
194	lang := ""
195	if lexer != nil && lexer.Config() != nil {
196		lang = lexer.Config().Name
197	}
198	var c string
199	if lang == "markdown" {
200		md, err := r.glamourize(width, content)
201		if err != nil {
202			return "", err
203		}
204		c = md
205	} else {
206		formatter := &gansi.CodeBlockElement{
207			Code:     content,
208			Language: lang,
209		}
210		s := strings.Builder{}
211		rc := r.renderContext
212		if r.showLineNumber {
213			st := common.StyleConfig()
214			var m uint
215			st.CodeBlock.Margin = &m
216			rc = gansi.NewRenderContext(gansi.Options{
217				ColorProfile: termenv.TrueColor,
218				Styles:       st,
219			})
220		}
221		err := formatter.Render(&s, rc)
222		if err != nil {
223			return "", err
224		}
225		c = s.String()
226		if r.showLineNumber {
227			var ml int
228			c, ml = withLineNumber(c)
229			width -= ml
230		}
231	}
232	// Fix styling when after line breaks.
233	// https://github.com/muesli/reflow/issues/43
234	//
235	// TODO: solve this upstream in Glamour/Reflow.
236	return lipgloss.NewStyle().Width(width).Render(c), nil
237}
238
239func withLineNumber(s string) (string, int) {
240	lines := strings.Split(s, "\n")
241	// NB: len() is not a particularly safe way to count string width (because
242	// it's counting bytes instead of runes) but in this case it's okay
243	// because we're only dealing with digits, which are one byte each.
244	mll := len(fmt.Sprintf("%d", len(lines)))
245	for i, l := range lines {
246		digit := fmt.Sprintf("%*d", mll, i+1)
247		bar := "│"
248		digit = lineDigitStyle.Render(digit)
249		bar = lineBarStyle.Render(bar)
250		if i < len(lines)-1 || len(l) != 0 {
251			// If the final line was a newline we'll get an empty string for
252			// the final line, so drop the newline altogether.
253			lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
254		}
255	}
256	return strings.Join(lines, "\n"), mll
257}