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}