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/reflow/wrap"
 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.CodeNoContent.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.(type) {
103	case tea.WindowSizeMsg:
104		// Recalculate content width and line wrap.
105		cmds = append(cmds, r.Init())
106	}
107	v, cmd := r.Viewport.Update(msg)
108	r.Viewport = v.(*vp.Viewport)
109	if cmd != nil {
110		cmds = append(cmds, cmd)
111	}
112	return r, tea.Batch(cmds...)
113}
114
115// View implements tea.View.
116func (r *Code) View() string {
117	return r.Viewport.View()
118}
119
120// GotoTop moves the viewport to the top of the log.
121func (r *Code) GotoTop() {
122	r.Viewport.GotoTop()
123}
124
125// GotoBottom moves the viewport to the bottom of the log.
126func (r *Code) GotoBottom() {
127	r.Viewport.GotoBottom()
128}
129
130// HalfViewDown moves the viewport down by half the viewport height.
131func (r *Code) HalfViewDown() {
132	r.Viewport.HalfViewDown()
133}
134
135// HalfViewUp moves the viewport up by half the viewport height.
136func (r *Code) HalfViewUp() {
137	r.Viewport.HalfViewUp()
138}
139
140// ViewUp moves the viewport up by a page.
141func (r *Code) ViewUp() []string {
142	return r.Viewport.ViewUp()
143}
144
145// ViewDown moves the viewport down by a page.
146func (r *Code) ViewDown() []string {
147	return r.Viewport.ViewDown()
148}
149
150// LineUp moves the viewport up by the given number of lines.
151func (r *Code) LineUp(n int) []string {
152	return r.Viewport.LineUp(n)
153}
154
155// LineDown moves the viewport down by the given number of lines.
156func (r *Code) LineDown(n int) []string {
157	return r.Viewport.LineDown(n)
158}
159
160// ScrollPercent returns the viewport's scroll percentage.
161func (r *Code) ScrollPercent() float64 {
162	return r.Viewport.ScrollPercent()
163}
164
165func (r *Code) glamourize(w int, md string) (string, error) {
166	r.renderMutex.Lock()
167	defer r.renderMutex.Unlock()
168	// This fixes a bug with markdown text wrapping being off by one.
169	if w > 0 {
170		w--
171	}
172	tr, err := glamour.NewTermRenderer(
173		glamour.WithStyles(r.styleConfig),
174		glamour.WithWordWrap(w),
175	)
176
177	if err != nil {
178		return "", err
179	}
180	mdt, err := tr.Render(md)
181	if err != nil {
182		return "", err
183	}
184	return mdt, nil
185}
186
187func (r *Code) renderFile(path, content string, width int) (string, error) {
188	// FIXME chroma & glamour might break wrapping when using tabs since tab
189	// width depends on the terminal. This is a workaround to replace tabs with
190	// 4-spaces.
191	content = strings.ReplaceAll(content, "\t", strings.Repeat(" ", tabWidth))
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	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		c = withLineNumber(c)
231	}
232	// FIXME: this is a hack to reset formatting at the end of every line.
233	c = wrap.String(c, width)
234	f := strings.Split(c, "\n")
235	for i, l := range f {
236		f[i] = l + "\x1b[0m"
237	}
238	return strings.Join(f, "\n"), nil
239}
240
241func withLineNumber(s string) string {
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")
259}