1package code
2
3import (
4 "fmt"
5 "strings"
6 "sync"
7
8 "github.com/alecthomas/chroma/lexers"
9 tea "github.com/charmbracelet/bubbletea"
10 "github.com/charmbracelet/glamour"
11 gansi "github.com/charmbracelet/glamour/ansi"
12 "github.com/charmbracelet/lipgloss"
13 "github.com/charmbracelet/soft-serve/ui/common"
14 vp "github.com/charmbracelet/soft-serve/ui/components/viewport"
15 "github.com/muesli/reflow/wrap"
16 "github.com/muesli/termenv"
17)
18
19var (
20 lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
21 lineBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
22)
23
24// Code is a code snippet.
25type Code struct {
26 *vp.Viewport
27 common common.Common
28 content string
29 extension string
30 renderContext gansi.RenderContext
31 renderMutex sync.Mutex
32 styleConfig gansi.StyleConfig
33 showLineNumber bool
34
35 NoContentStyle lipgloss.Style
36 LineDigitStyle lipgloss.Style
37 LineBarStyle lipgloss.Style
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 Viewport: vp.New(c),
47 NoContentStyle: c.Styles.CodeNoContent.Copy(),
48 LineDigitStyle: lineDigitStyle,
49 LineBarStyle: lineBarStyle,
50 }
51 st := common.StyleConfig()
52 r.styleConfig = st
53 r.renderContext = gansi.NewRenderContext(gansi.Options{
54 ColorProfile: termenv.TrueColor,
55 Styles: st,
56 })
57 r.SetSize(c.Width, c.Height)
58 return r
59}
60
61// SetShowLineNumber sets whether to show line numbers.
62func (r *Code) SetShowLineNumber(show bool) {
63 r.showLineNumber = show
64}
65
66// SetSize implements common.Component.
67func (r *Code) SetSize(width, height int) {
68 r.common.SetSize(width, height)
69 r.Viewport.SetSize(width, height)
70}
71
72// SetContent sets the content of the Code.
73func (r *Code) SetContent(c, ext string) tea.Cmd {
74 r.content = c
75 r.extension = ext
76 return r.Init()
77}
78
79// Init implements tea.Model.
80func (r *Code) Init() tea.Cmd {
81 w := r.common.Width
82 c := r.content
83 if c == "" {
84 r.Viewport.Model.SetContent(r.NoContentStyle.String())
85 return nil
86 }
87 f, err := r.renderFile(r.extension, c, w)
88 if err != nil {
89 return common.ErrorCmd(err)
90 }
91 r.Viewport.Model.SetContent(f)
92 return nil
93}
94
95// Update implements tea.Model.
96func (r *Code) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
97 cmds := make([]tea.Cmd, 0)
98 switch msg.(type) {
99 case tea.WindowSizeMsg:
100 // Recalculate content width and line wrap.
101 cmds = append(cmds, r.Init())
102 }
103 v, cmd := r.Viewport.Update(msg)
104 r.Viewport = v.(*vp.Viewport)
105 if cmd != nil {
106 cmds = append(cmds, cmd)
107 }
108 return r, tea.Batch(cmds...)
109}
110
111// View implements tea.View.
112func (r *Code) View() string {
113 return r.Viewport.View()
114}
115
116// GotoTop moves the viewport to the top of the log.
117func (r *Code) GotoTop() {
118 r.Viewport.GotoTop()
119}
120
121// GotoBottom moves the viewport to the bottom of the log.
122func (r *Code) GotoBottom() {
123 r.Viewport.GotoBottom()
124}
125
126// HalfViewDown moves the viewport down by half the viewport height.
127func (r *Code) HalfViewDown() {
128 r.Viewport.HalfViewDown()
129}
130
131// HalfViewUp moves the viewport up by half the viewport height.
132func (r *Code) HalfViewUp() {
133 r.Viewport.HalfViewUp()
134}
135
136// ViewUp moves the viewport up by a page.
137func (r *Code) ViewUp() []string {
138 return r.Viewport.ViewUp()
139}
140
141// ViewDown moves the viewport down by a page.
142func (r *Code) ViewDown() []string {
143 return r.Viewport.ViewDown()
144}
145
146// LineUp moves the viewport up by the given number of lines.
147func (r *Code) LineUp(n int) []string {
148 return r.Viewport.LineUp(n)
149}
150
151// LineDown moves the viewport down by the given number of lines.
152func (r *Code) LineDown(n int) []string {
153 return r.Viewport.LineDown(n)
154}
155
156// ScrollPercent returns the viewport's scroll percentage.
157func (r *Code) ScrollPercent() float64 {
158 return r.Viewport.ScrollPercent()
159}
160
161func (r *Code) glamourize(w int, md string) (string, error) {
162 r.renderMutex.Lock()
163 defer r.renderMutex.Unlock()
164 tr, err := glamour.NewTermRenderer(
165 glamour.WithStyles(r.styleConfig),
166 glamour.WithWordWrap(w),
167 )
168
169 if err != nil {
170 return "", err
171 }
172 mdt, err := tr.Render(md)
173 if err != nil {
174 return "", err
175 }
176 return mdt, nil
177}
178
179func (r *Code) renderFile(path, content string, width int) (string, error) {
180 lexer := lexers.Fallback
181 if path == "" {
182 lexer = lexers.Analyse(content)
183 } else {
184 lexer = lexers.Match(path)
185 }
186 lang := ""
187 if lexer != nil && lexer.Config() != nil {
188 lang = lexer.Config().Name
189 }
190 if lang == "markdown" {
191 md, err := r.glamourize(width, content)
192 if err != nil {
193 return "", err
194 }
195 return md, nil
196 }
197 formatter := &gansi.CodeBlockElement{
198 Code: content,
199 Language: lang,
200 }
201 s := strings.Builder{}
202 err := formatter.Render(&s, r.renderContext)
203 if err != nil {
204 return "", err
205 }
206 c := s.String()
207 if r.showLineNumber {
208 c = withLineNumber(c)
209 }
210 // FIXME: this is a hack to reset formatting at the end of every line.
211 c = wrap.String(c, width)
212 f := strings.Split(c, "\n")
213 for i, l := range f {
214 f[i] = l + "\x1b[0m"
215 }
216 return strings.Join(f, "\n"), nil
217}
218
219func withLineNumber(s string) string {
220 lines := strings.Split(s, "\n")
221 // NB: len() is not a particularly safe way to count string width (because
222 // it's counting bytes instead of runes) but in this case it's okay
223 // because we're only dealing with digits, which are one byte each.
224 mll := len(fmt.Sprintf("%d", len(lines)))
225 for i, l := range lines {
226 digit := fmt.Sprintf("%*d", mll, i+1)
227 bar := "│"
228 digit = lineDigitStyle.Render(digit)
229 bar = lineBarStyle.Render(bar)
230 if i < len(lines)-1 || len(l) != 0 {
231 // If the final line was a newline we'll get an empty string for
232 // the final line, so drop the newline altogether.
233 lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
234 }
235 }
236 return strings.Join(lines, "\n")
237}