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