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	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.Fallback
191	if path == "" {
192		lexer = lexers.Analyse(content)
193	} else {
194		lexer = lexers.Match(path)
195	}
196	lang := ""
197	if lexer != nil && lexer.Config() != nil {
198		lang = lexer.Config().Name
199	}
200	var c string
201	if lang == "markdown" {
202		md, err := r.glamourize(width, content)
203		if err != nil {
204			return "", err
205		}
206		c = md
207	} else {
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	}
234	// Fix styling when after line breaks.
235	// https://github.com/muesli/reflow/issues/43
236	//
237	// TODO: solve this upstream in Glamour/Reflow.
238	return lipgloss.NewStyle().Width(width).Render(c), nil
239}
240
241func withLineNumber(s string) (string, int) {
242	lines := strings.Split(s, "\n")
243	// NB: len() is not a particularly safe way to count string width (because
244	// it's counting bytes instead of runes) but in this case it's okay
245	// because we're only dealing with digits, which are one byte each.
246	mll := len(fmt.Sprintf("%d", len(lines)))
247	for i, l := range lines {
248		digit := fmt.Sprintf("%*d", mll, i+1)
249		bar := "│"
250		digit = lineDigitStyle.Render(digit)
251		bar = lineBarStyle.Render(bar)
252		if i < len(lines)-1 || len(l) != 0 {
253			// If the final line was a newline we'll get an empty string for
254			// the final line, so drop the newline altogether.
255			lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
256		}
257	}
258	return strings.Join(lines, "\n"), mll
259}