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	var c string
202	if lang == "markdown" {
203		md, err := r.glamourize(width, content)
204		if err != nil {
205			return "", err
206		}
207		c = md
208	} else {
209		formatter := &gansi.CodeBlockElement{
210			Code:     content,
211			Language: lang,
212		}
213		s := strings.Builder{}
214		rc := r.renderContext
215		if r.showLineNumber {
216			st := common.StyleConfig()
217			m := uint(0)
218			st.CodeBlock.Margin = &m
219			rc = gansi.NewRenderContext(gansi.Options{
220				ColorProfile: termenv.TrueColor,
221				Styles:       st,
222			})
223		}
224		err := formatter.Render(&s, rc)
225		if err != nil {
226			return "", err
227		}
228		c = s.String()
229		if r.showLineNumber {
230			var ml int
231			c, ml = withLineNumber(c)
232			width -= ml
233		}
234	}
235	// Fix styling when after line breaks.
236	// https://github.com/muesli/reflow/issues/43
237	//
238	// TODO: solve this upstream in Glamour/Reflow.
239	return lipgloss.NewStyle().Width(width).Render(c), nil
240}
241
242func withLineNumber(s string) (string, int) {
243	lines := strings.Split(s, "\n")
244	// NB: len() is not a particularly safe way to count string width (because
245	// it's counting bytes instead of runes) but in this case it's okay
246	// because we're only dealing with digits, which are one byte each.
247	mll := len(fmt.Sprintf("%d", len(lines)))
248	for i, l := range lines {
249		digit := fmt.Sprintf("%*d", mll, i+1)
250		bar := "│"
251		digit = lineDigitStyle.Render(digit)
252		bar = lineBarStyle.Render(bar)
253		if i < len(lines)-1 || len(l) != 0 {
254			// If the final line was a newline we'll get an empty string for
255			// the final line, so drop the newline altogether.
256			lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
257		}
258	}
259	return strings.Join(lines, "\n"), mll
260}