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 // This fixes a bug with markdown text wrapping being off by one.
165 if w > 0 {
166 w--
167 }
168 tr, err := glamour.NewTermRenderer(
169 glamour.WithStyles(r.styleConfig),
170 glamour.WithWordWrap(w),
171 )
172
173 if err != nil {
174 return "", err
175 }
176 mdt, err := tr.Render(md)
177 if err != nil {
178 return "", err
179 }
180 return mdt, nil
181}
182
183func (r *Code) renderFile(path, content string, width int) (string, error) {
184 lexer := lexers.Fallback
185 if path == "" {
186 lexer = lexers.Analyse(content)
187 } else {
188 lexer = lexers.Match(path)
189 }
190 lang := ""
191 if lexer != nil && lexer.Config() != nil {
192 lang = lexer.Config().Name
193 }
194 if lang == "markdown" {
195 md, err := r.glamourize(width, content)
196 if err != nil {
197 return "", err
198 }
199 return md, nil
200 }
201 formatter := &gansi.CodeBlockElement{
202 Code: content,
203 Language: lang,
204 }
205 s := strings.Builder{}
206 rc := r.renderContext
207 if r.showLineNumber {
208 st := common.StyleConfig()
209 m := uint(0)
210 st.CodeBlock.Margin = &m
211 rc = gansi.NewRenderContext(gansi.Options{
212 ColorProfile: termenv.TrueColor,
213 Styles: st,
214 })
215 }
216 err := formatter.Render(&s, rc)
217 if err != nil {
218 return "", err
219 }
220 c := s.String()
221 if r.showLineNumber {
222 c = withLineNumber(c)
223 }
224 // FIXME: this is a hack to reset formatting at the end of every line.
225 c = wrap.String(c, width)
226 f := strings.Split(c, "\n")
227 for i, l := range f {
228 f[i] = l + "\x1b[0m"
229 }
230 return strings.Join(f, "\n"), nil
231}
232
233func withLineNumber(s string) string {
234 lines := strings.Split(s, "\n")
235 // NB: len() is not a particularly safe way to count string width (because
236 // it's counting bytes instead of runes) but in this case it's okay
237 // because we're only dealing with digits, which are one byte each.
238 mll := len(fmt.Sprintf("%d", len(lines)))
239 for i, l := range lines {
240 digit := fmt.Sprintf("%*d", mll, i+1)
241 bar := "│"
242 digit = lineDigitStyle.Render(digit)
243 bar = lineBarStyle.Render(bar)
244 if i < len(lines)-1 || len(l) != 0 {
245 // If the final line was a newline we'll get an empty string for
246 // the final line, so drop the newline altogether.
247 lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
248 }
249 }
250 return strings.Join(lines, "\n")
251}