code.go

  1package code
  2
  3import (
  4	"strings"
  5
  6	"github.com/alecthomas/chroma/lexers"
  7	tea "github.com/charmbracelet/bubbletea"
  8	"github.com/charmbracelet/glamour"
  9	gansi "github.com/charmbracelet/glamour/ansi"
 10	"github.com/charmbracelet/lipgloss"
 11	"github.com/charmbracelet/soft-serve/ui/common"
 12	vp "github.com/charmbracelet/soft-serve/ui/components/viewport"
 13	"github.com/muesli/reflow/wrap"
 14	"github.com/muesli/termenv"
 15)
 16
 17// Code is a code snippet.
 18type Code struct {
 19	*vp.Viewport
 20	common         common.Common
 21	content        string
 22	extension      string
 23	NoContentStyle lipgloss.Style
 24}
 25
 26// New returns a new Code.
 27func New(c common.Common, content, extension string) *Code {
 28	r := &Code{
 29		common:         c,
 30		content:        content,
 31		extension:      extension,
 32		Viewport:       vp.New(c),
 33		NoContentStyle: c.Styles.CodeNoContent.Copy(),
 34	}
 35	r.SetSize(c.Width, c.Height)
 36	return r
 37}
 38
 39// SetSize implements common.Component.
 40func (r *Code) SetSize(width, height int) {
 41	r.common.SetSize(width, height)
 42	r.Viewport.SetSize(width, height)
 43}
 44
 45// SetContent sets the content of the Code.
 46func (r *Code) SetContent(c, ext string) tea.Cmd {
 47	r.content = c
 48	r.extension = ext
 49	return r.Init()
 50}
 51
 52// Init implements tea.Model.
 53func (r *Code) Init() tea.Cmd {
 54	w := r.common.Width
 55	c := r.content
 56	if c == "" {
 57		c = r.NoContentStyle.String()
 58	}
 59	f, err := renderFile(r.extension, c, w)
 60	if err != nil {
 61		return common.ErrorCmd(err)
 62	}
 63	// FIXME: this is a hack to reset formatting at the end of every line.
 64	c = wrap.String(f, w)
 65	s := strings.Split(c, "\n")
 66	for i, l := range s {
 67		s[i] = l + "\x1b[0m"
 68	}
 69	r.Viewport.Model.SetContent(strings.Join(s, "\n"))
 70	return nil
 71}
 72
 73// Update implements tea.Model.
 74func (r *Code) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 75	cmds := make([]tea.Cmd, 0)
 76	switch msg.(type) {
 77	case tea.WindowSizeMsg:
 78		// Recalculate content width and line wrap.
 79		cmds = append(cmds, r.Init())
 80	}
 81	v, cmd := r.Viewport.Update(msg)
 82	r.Viewport = v.(*vp.Viewport)
 83	if cmd != nil {
 84		cmds = append(cmds, cmd)
 85	}
 86	return r, tea.Batch(cmds...)
 87}
 88
 89// View implements tea.View.
 90func (r *Code) View() string {
 91	return r.Viewport.View()
 92}
 93
 94// GotoTop moves the viewport to the top of the log.
 95func (r *Code) GotoTop() {
 96	r.Viewport.GotoTop()
 97}
 98
 99// GotoBottom moves the viewport to the bottom of the log.
100func (r *Code) GotoBottom() {
101	r.Viewport.GotoBottom()
102}
103
104// HalfViewDown moves the viewport down by half the viewport height.
105func (r *Code) HalfViewDown() {
106	r.Viewport.HalfViewDown()
107}
108
109// HalfViewUp moves the viewport up by half the viewport height.
110func (r *Code) HalfViewUp() {
111	r.Viewport.HalfViewUp()
112}
113
114// ViewUp moves the viewport up by a page.
115func (r *Code) ViewUp() []string {
116	return r.Viewport.ViewUp()
117}
118
119// ViewDown moves the viewport down by a page.
120func (r *Code) ViewDown() []string {
121	return r.Viewport.ViewDown()
122}
123
124// LineUp moves the viewport up by the given number of lines.
125func (r *Code) LineUp(n int) []string {
126	return r.Viewport.LineUp(n)
127}
128
129// LineDown moves the viewport down by the given number of lines.
130func (r *Code) LineDown(n int) []string {
131	return r.Viewport.LineDown(n)
132}
133
134// ScrollPercent returns the viewport's scroll percentage.
135func (r *Code) ScrollPercent() float64 {
136	return r.Viewport.ScrollPercent()
137}
138
139func styleConfig() gansi.StyleConfig {
140	noColor := ""
141	s := glamour.DarkStyleConfig
142	// This fixes an issue with the default style config. For example
143	// highlighting empty spaces with red in Dockerfile type.
144	s.Document.StylePrimitive.Color = &noColor
145	s.CodeBlock.Chroma.Text.Color = &noColor
146	s.CodeBlock.Chroma.Name.Color = &noColor
147	return s
148}
149
150func renderCtx() gansi.RenderContext {
151	return gansi.NewRenderContext(gansi.Options{
152		ColorProfile: termenv.TrueColor,
153		Styles:       styleConfig(),
154	})
155}
156
157func glamourize(w int, md string) (string, error) {
158	if w > 120 {
159		w = 120
160	}
161	tr, err := glamour.NewTermRenderer(
162		glamour.WithStyles(styleConfig()),
163		glamour.WithWordWrap(w),
164	)
165
166	if err != nil {
167		return "", err
168	}
169	mdt, err := tr.Render(md)
170	if err != nil {
171		return "", err
172	}
173	return mdt, nil
174}
175
176func renderFile(path, content string, width int) (string, error) {
177	lexer := lexers.Fallback
178	if path == "" {
179		lexer = lexers.Analyse(content)
180	} else {
181		lexer = lexers.Match(path)
182	}
183	lang := ""
184	if lexer != nil && lexer.Config() != nil {
185		lang = lexer.Config().Name
186	}
187	if lang == "markdown" {
188		md, err := glamourize(width, content)
189		if err != nil {
190			return "", err
191		}
192		return md, nil
193	}
194	formatter := &gansi.CodeBlockElement{
195		Code:     content,
196		Language: lang,
197	}
198	r := strings.Builder{}
199	err := formatter.Render(&r, renderCtx())
200	if err != nil {
201		return "", err
202	}
203	return r.String(), nil
204}