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/v2"
 11	gansi "github.com/charmbracelet/glamour/v2/ansi"
 12	lipgloss "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)
 16
 17const (
 18	defaultTabWidth        = 4
 19	defaultSideNotePercent = 0.3
 20)
 21
 22// Code is a code snippet.
 23type Code struct {
 24	*vp.Viewport
 25	common        common.Common
 26	sidenote      string
 27	content       string
 28	extension     string
 29	renderContext gansi.RenderContext
 30	renderMutex   sync.Mutex
 31	styleConfig   gansi.StyleConfig
 32
 33	SideNotePercent float64
 34	TabWidth        int
 35	ShowLineNumber  bool
 36	NoContentStyle  lipgloss.Style
 37	UseGlamour      bool
 38}
 39
 40// New returns a new Code.
 41func New(c common.Common, content, extension string) *Code {
 42	r := &Code{
 43		common:          c,
 44		content:         content,
 45		extension:       extension,
 46		TabWidth:        defaultTabWidth,
 47		SideNotePercent: defaultSideNotePercent,
 48		Viewport:        vp.New(c),
 49		NoContentStyle:  c.Styles.NoContent.SetString("No Content."),
 50	}
 51	st := common.StyleConfig()
 52	r.styleConfig = st
 53	r.renderContext = common.StyleRendererWithStyles(st)
 54	r.SetSize(c.Width, c.Height)
 55	return r
 56}
 57
 58// SetSize implements common.Component.
 59func (r *Code) SetSize(width, height int) {
 60	r.common.SetSize(width, height)
 61	r.Viewport.SetSize(width, height)
 62}
 63
 64// SetContent sets the content of the Code.
 65func (r *Code) SetContent(c, ext string) tea.Cmd {
 66	r.content = c
 67	r.extension = ext
 68	return r.Init()
 69}
 70
 71// SetSideNote sets the sidenote of the Code.
 72func (r *Code) SetSideNote(s string) tea.Cmd {
 73	r.sidenote = s
 74	return r.Init()
 75}
 76
 77// Init implements tea.Model.
 78func (r *Code) Init() tea.Cmd {
 79	// XXX: We probably won't need the GetHorizontalFrameSize margin
 80	// subtraction if we get the new viewport soft wrapping to play nicely with
 81	// Glamour. This also introduces a bug where when it soft wraps, the
 82	// viewport scrolls left/right for 2 columns on each side of the screen.
 83	w := r.common.Width - r.common.Styles.App.GetHorizontalFrameSize()
 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 && common.IsFileMarkdown(content, r.extension) {
 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.Viewport.Width()) * r.SideNotePercent))
117		for i, l := range lines {
118			lines[i] = common.TruncateString(l, sideNoteWidth)
119		}
120		content = lipgloss.JoinHorizontal(lipgloss.Top, 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// ScrollPercent returns the viewport's scroll percentage.
176func (r *Code) ScrollPercent() float64 {
177	return r.Viewport.ScrollPercent()
178}
179
180// ScrollPosition returns the viewport's scroll position.
181func (r *Code) ScrollPosition() int {
182	scroll := r.ScrollPercent() * 100
183	if scroll < 0 || math.IsNaN(scroll) {
184		scroll = 0
185	}
186	return int(scroll)
187}
188
189func (r *Code) glamourize(w int, md string) (string, error) {
190	r.renderMutex.Lock()
191	defer r.renderMutex.Unlock()
192	if w > 120 {
193		w = 120
194	}
195	tr, err := glamour.NewTermRenderer(
196		glamour.WithStyles(r.styleConfig),
197		glamour.WithWordWrap(w),
198	)
199	if err != nil {
200		return "", err
201	}
202	mdt, err := tr.Render(md)
203	if err != nil {
204		return "", err
205	}
206	return mdt, nil
207}
208
209func (r *Code) renderFile(path, content string) (string, error) {
210	lexer := lexers.Match(path)
211	if path == "" {
212		lexer = lexers.Analyse(content)
213	}
214	lang := ""
215	if lexer != nil && lexer.Config() != nil {
216		lang = lexer.Config().Name
217	}
218
219	formatter := &gansi.CodeBlockElement{
220		Code:     content,
221		Language: lang,
222	}
223	s := strings.Builder{}
224	rc := r.renderContext
225	if r.ShowLineNumber {
226		st := common.StyleConfig()
227		var m uint
228		st.CodeBlock.Margin = &m
229		rc = gansi.NewRenderContext(gansi.Options{
230			Styles: st,
231		})
232	}
233	err := formatter.Render(&s, rc)
234	if err != nil {
235		return "", err
236	}
237
238	return s.String(), nil
239}