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 rc := r.renderContext
203 if r.showLineNumber {
204 st := common.StyleConfig()
205 var m uint = 0
206 st.CodeBlock.Margin = &m
207 rc = gansi.NewRenderContext(gansi.Options{
208 ColorProfile: termenv.TrueColor,
209 Styles: st,
210 })
211 }
212 err := formatter.Render(&s, rc)
213 if err != nil {
214 return "", err
215 }
216 c := s.String()
217 if r.showLineNumber {
218 c = withLineNumber(c)
219 }
220 // FIXME: this is a hack to reset formatting at the end of every line.
221 c = wrap.String(c, width)
222 f := strings.Split(c, "\n")
223 for i, l := range f {
224 f[i] = l + "\x1b[0m"
225 }
226 return strings.Join(f, "\n"), nil
227}
228
229func withLineNumber(s string) string {
230 lines := strings.Split(s, "\n")
231 // NB: len() is not a particularly safe way to count string width (because
232 // it's counting bytes instead of runes) but in this case it's okay
233 // because we're only dealing with digits, which are one byte each.
234 mll := len(fmt.Sprintf("%d", len(lines)))
235 for i, l := range lines {
236 digit := fmt.Sprintf("%*d", mll, i+1)
237 bar := "│"
238 digit = lineDigitStyle.Render(digit)
239 bar = lineBarStyle.Render(bar)
240 if i < len(lines)-1 || len(l) != 0 {
241 // If the final line was a newline we'll get an empty string for
242 // the final line, so drop the newline altogether.
243 lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
244 }
245 }
246 return strings.Join(lines, "\n")
247}