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