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