code.go

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