code.go

  1// Package code provides code syntax highlighting components.
  2package code
  3
  4import (
  5	"math"
  6	"strings"
  7	"sync"
  8
  9	"github.com/alecthomas/chroma/v2/lexers"
 10	tea "github.com/charmbracelet/bubbletea/v2"
 11	"github.com/charmbracelet/glamour/v2"
 12	gansi "github.com/charmbracelet/glamour/v2/ansi"
 13	"github.com/charmbracelet/lipgloss/v2"
 14	"github.com/charmbracelet/soft-serve/pkg/ui/common"
 15	vp "github.com/charmbracelet/soft-serve/pkg/ui/components/viewport"
 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.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	// XXX: We probably won't need the GetHorizontalFrameSize margin
 81	// subtraction if we get the new viewport soft wrapping to play nicely with
 82	// Glamour. This also introduces a bug where when it soft wraps, the
 83	// viewport scrolls left/right for 2 columns on each side of the screen.
 84	w := r.common.Width - r.common.Styles.App.GetHorizontalFrameSize()
 85	content := r.content
 86	if content == "" {
 87		r.Model.SetContent(r.NoContentStyle.String())
 88		return nil
 89	}
 90
 91	// FIXME chroma & glamour might break wrapping when using tabs since tab
 92	// width depends on the terminal. This is a workaround to replace tabs with
 93	// 4-spaces.
 94	content = strings.ReplaceAll(content, "\t", strings.Repeat(" ", r.TabWidth))
 95
 96	if r.UseGlamour && common.IsFileMarkdown(content, r.extension) {
 97		md, err := r.glamourize(w, content)
 98		if err != nil {
 99			return common.ErrorCmd(err)
100		}
101		content = md
102	} else {
103		f, err := r.renderFile(r.extension, content)
104		if err != nil {
105			return common.ErrorCmd(err)
106		}
107		content = f
108		if r.ShowLineNumber {
109			var ml int
110			content, ml = common.FormatLineNumber(r.common.Styles, content, true)
111			w -= ml
112		}
113	}
114
115	if r.sidenote != "" {
116		lines := strings.Split(r.sidenote, "\n")
117		sideNoteWidth := int(math.Ceil(float64(r.Width()) * r.SideNotePercent))
118		for i, l := range lines {
119			lines[i] = common.TruncateString(l, sideNoteWidth)
120		}
121		content = lipgloss.JoinHorizontal(lipgloss.Top, strings.Join(lines, "\n"), content)
122	}
123
124	// Fix styles after hard wrapping
125	// https://github.com/muesli/reflow/issues/43
126	//
127	// TODO: solve this upstream in Glamour/Reflow.
128	content = lipgloss.NewStyle().Width(w).Render(content)
129
130	r.Model.SetContent(content)
131
132	return nil
133}
134
135// Update implements tea.Model.
136func (r *Code) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
137	cmds := make([]tea.Cmd, 0)
138	switch msg.(type) {
139	case tea.WindowSizeMsg:
140		// Recalculate content width and line wrap.
141		cmds = append(cmds, r.Init())
142	}
143	v, cmd := r.Viewport.Update(msg)
144	r.Viewport = v.(*vp.Viewport)
145	if cmd != nil {
146		cmds = append(cmds, cmd)
147	}
148	return r, tea.Batch(cmds...)
149}
150
151// View implements tea.View.
152func (r *Code) View() string {
153	return r.Viewport.View()
154}
155
156// GotoTop moves the viewport to the top of the log.
157func (r *Code) GotoTop() {
158	r.Viewport.GotoTop()
159}
160
161// GotoBottom moves the viewport to the bottom of the log.
162func (r *Code) GotoBottom() {
163	r.Viewport.GotoBottom()
164}
165
166// HalfViewDown moves the viewport down by half the viewport height.
167func (r *Code) HalfViewDown() {
168	r.Viewport.HalfViewDown()
169}
170
171// HalfViewUp moves the viewport up by half the viewport height.
172func (r *Code) HalfViewUp() {
173	r.Viewport.HalfViewUp()
174}
175
176// ScrollPercent returns the viewport's scroll percentage.
177func (r *Code) ScrollPercent() float64 {
178	return r.Viewport.ScrollPercent()
179}
180
181// ScrollPosition returns the viewport's scroll position.
182func (r *Code) ScrollPosition() int {
183	scroll := r.ScrollPercent() * 100
184	if scroll < 0 || math.IsNaN(scroll) {
185		scroll = 0
186	}
187	return int(scroll)
188}
189
190func (r *Code) glamourize(w int, md string) (string, error) {
191	r.renderMutex.Lock()
192	defer r.renderMutex.Unlock()
193	if w > 120 {
194		w = 120
195	}
196	tr, err := glamour.NewTermRenderer(
197		glamour.WithStyles(r.styleConfig),
198		glamour.WithWordWrap(w),
199	)
200	if err != nil {
201		return "", err //nolint:wrapcheck
202	}
203	mdt, err := tr.Render(md)
204	if err != nil {
205		return "", err //nolint:wrapcheck
206	}
207	return mdt, nil
208}
209
210func (r *Code) renderFile(path, content string) (string, error) {
211	lexer := lexers.Match(path)
212	if path == "" {
213		lexer = lexers.Analyse(content)
214	}
215	lang := ""
216	if lexer != nil && lexer.Config() != nil {
217		lang = lexer.Config().Name
218	}
219
220	formatter := &gansi.CodeBlockElement{
221		Code:     content,
222		Language: lang,
223	}
224	s := strings.Builder{}
225	rc := r.renderContext
226	if r.ShowLineNumber {
227		st := common.StyleConfig()
228		var m uint
229		st.CodeBlock.Margin = &m
230		rc = gansi.NewRenderContext(gansi.Options{
231			Styles: st,
232		})
233	}
234	err := formatter.Render(&s, rc)
235	if err != nil {
236		return "", err //nolint:wrapcheck
237	}
238
239	return s.String(), nil
240}