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