code.go

  1package code
  2
  3import (
  4	"math"
  5	"strings"
  6	"sync"
  7
  8	"github.com/alecthomas/chroma/v2/lexers"
  9	tea "github.com/charmbracelet/bubbletea/v2"
 10	"github.com/charmbracelet/glamour"
 11	gansi "github.com/charmbracelet/glamour/ansi"
 12	"github.com/charmbracelet/lipgloss/v2"
 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.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 && common.IsFileMarkdown(content, r.extension) {
 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.Top, 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 = lipgloss.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// ScrollPercent returns the viewport's scroll percentage.
173func (r *Code) ScrollPercent() float64 {
174	return r.Viewport.ScrollPercent()
175}
176
177// ScrollPosition returns the viewport's scroll position.
178func (r *Code) ScrollPosition() int {
179	scroll := r.ScrollPercent() * 100
180	if scroll < 0 || math.IsNaN(scroll) {
181		scroll = 0
182	}
183	return int(scroll)
184}
185
186func (r *Code) glamourize(w int, md string) (string, error) {
187	r.renderMutex.Lock()
188	defer r.renderMutex.Unlock()
189	if w > 120 {
190		w = 120
191	}
192	tr, err := glamour.NewTermRenderer(
193		glamour.WithStyles(r.styleConfig),
194		glamour.WithWordWrap(w),
195	)
196	if err != nil {
197		return "", err
198	}
199	mdt, err := tr.Render(md)
200	if err != nil {
201		return "", err
202	}
203	return mdt, nil
204}
205
206func (r *Code) renderFile(path, content string) (string, error) {
207	lexer := lexers.Match(path)
208	if path == "" {
209		lexer = lexers.Analyse(content)
210	}
211	lang := ""
212	if lexer != nil && lexer.Config() != nil {
213		lang = lexer.Config().Name
214	}
215
216	formatter := &gansi.CodeBlockElement{
217		Code:     content,
218		Language: lang,
219	}
220	s := strings.Builder{}
221	rc := r.renderContext
222	if r.ShowLineNumber {
223		st := common.StyleConfig()
224		var m uint
225		st.CodeBlock.Margin = &m
226		rc = gansi.NewRenderContext(gansi.Options{
227			ColorProfile: termenv.TrueColor,
228			Styles:       st,
229		})
230	}
231	err := formatter.Render(&s, rc)
232	if err != nil {
233		return "", err
234	}
235
236	return s.String(), nil
237}