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	if r.showLineNumber {
 92		f = withLineNumber(f)
 93	}
 94	// FIXME: this is a hack to reset formatting at the end of every line.
 95	c = wrap.String(f, w)
 96	s := strings.Split(c, "\n")
 97	for i, l := range s {
 98		s[i] = l + "\x1b[0m"
 99	}
100	r.Viewport.Model.SetContent(strings.Join(s, "\n"))
101	return nil
102}
103
104// Update implements tea.Model.
105func (r *Code) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
106	cmds := make([]tea.Cmd, 0)
107	switch msg.(type) {
108	case tea.WindowSizeMsg:
109		// Recalculate content width and line wrap.
110		cmds = append(cmds, r.Init())
111	}
112	v, cmd := r.Viewport.Update(msg)
113	r.Viewport = v.(*vp.Viewport)
114	if cmd != nil {
115		cmds = append(cmds, cmd)
116	}
117	return r, tea.Batch(cmds...)
118}
119
120// View implements tea.View.
121func (r *Code) View() string {
122	return r.Viewport.View()
123}
124
125// GotoTop moves the viewport to the top of the log.
126func (r *Code) GotoTop() {
127	r.Viewport.GotoTop()
128}
129
130// GotoBottom moves the viewport to the bottom of the log.
131func (r *Code) GotoBottom() {
132	r.Viewport.GotoBottom()
133}
134
135// HalfViewDown moves the viewport down by half the viewport height.
136func (r *Code) HalfViewDown() {
137	r.Viewport.HalfViewDown()
138}
139
140// HalfViewUp moves the viewport up by half the viewport height.
141func (r *Code) HalfViewUp() {
142	r.Viewport.HalfViewUp()
143}
144
145// ViewUp moves the viewport up by a page.
146func (r *Code) ViewUp() []string {
147	return r.Viewport.ViewUp()
148}
149
150// ViewDown moves the viewport down by a page.
151func (r *Code) ViewDown() []string {
152	return r.Viewport.ViewDown()
153}
154
155// LineUp moves the viewport up by the given number of lines.
156func (r *Code) LineUp(n int) []string {
157	return r.Viewport.LineUp(n)
158}
159
160// LineDown moves the viewport down by the given number of lines.
161func (r *Code) LineDown(n int) []string {
162	return r.Viewport.LineDown(n)
163}
164
165// ScrollPercent returns the viewport's scroll percentage.
166func (r *Code) ScrollPercent() float64 {
167	return r.Viewport.ScrollPercent()
168}
169
170func (r *Code) glamourize(w int, md string) (string, error) {
171	r.renderMutex.Lock()
172	defer r.renderMutex.Unlock()
173	if w > 120 {
174		w = 120
175	}
176	tr, err := glamour.NewTermRenderer(
177		glamour.WithStyles(r.styleConfig),
178		glamour.WithWordWrap(w),
179	)
180
181	if err != nil {
182		return "", err
183	}
184	mdt, err := tr.Render(md)
185	if err != nil {
186		return "", err
187	}
188	return mdt, nil
189}
190
191func (r *Code) renderFile(path, content string, width int) (string, error) {
192	lexer := lexers.Fallback
193	if path == "" {
194		lexer = lexers.Analyse(content)
195	} else {
196		lexer = lexers.Match(path)
197	}
198	lang := ""
199	if lexer != nil && lexer.Config() != nil {
200		lang = lexer.Config().Name
201	}
202	if lang == "markdown" {
203		md, err := r.glamourize(width, content)
204		if err != nil {
205			return "", err
206		}
207		return md, nil
208	}
209	formatter := &gansi.CodeBlockElement{
210		Code:     content,
211		Language: lang,
212	}
213	s := strings.Builder{}
214	err := formatter.Render(&s, r.renderContext)
215	if err != nil {
216		return "", err
217	}
218	return s.String(), nil
219}
220
221func withLineNumber(s string) string {
222	lines := strings.Split(s, "\n")
223	// NB: len() is not a particularly safe way to count string width (because
224	// it's counting bytes instead of runes) but in this case it's okay
225	// because we're only dealing with digits, which are one byte each.
226	mll := len(fmt.Sprintf("%d", len(lines)))
227	for i, l := range lines {
228		digit := fmt.Sprintf("%*d", mll, i+1)
229		bar := "│"
230		digit = lineDigitStyle.Render(digit)
231		bar = lineBarStyle.Render(bar)
232		if i < len(lines)-1 || len(l) != 0 {
233			// If the final line was a newline we'll get an empty string for
234			// the final line, so drop the newline altogether.
235			lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
236		}
237	}
238	return strings.Join(lines, "\n")
239}