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 var c string
202 if lang == "markdown" {
203 md, err := r.glamourize(width, content)
204 if err != nil {
205 return "", err
206 }
207 c = md
208 } else {
209 formatter := &gansi.CodeBlockElement{
210 Code: content,
211 Language: lang,
212 }
213 s := strings.Builder{}
214 rc := r.renderContext
215 if r.showLineNumber {
216 st := common.StyleConfig()
217 m := uint(0)
218 st.CodeBlock.Margin = &m
219 rc = gansi.NewRenderContext(gansi.Options{
220 ColorProfile: termenv.TrueColor,
221 Styles: st,
222 })
223 }
224 err := formatter.Render(&s, rc)
225 if err != nil {
226 return "", err
227 }
228 c = s.String()
229 if r.showLineNumber {
230 var ml int
231 c, ml = withLineNumber(c)
232 width -= ml
233 }
234 }
235 // Fix styling when after line breaks.
236 // https://github.com/muesli/reflow/issues/43
237 //
238 // TODO: solve this upstream in Glamour/Reflow.
239 return lipgloss.NewStyle().Width(width).Render(c), nil
240}
241
242func withLineNumber(s string) (string, int) {
243 lines := strings.Split(s, "\n")
244 // NB: len() is not a particularly safe way to count string width (because
245 // it's counting bytes instead of runes) but in this case it's okay
246 // because we're only dealing with digits, which are one byte each.
247 mll := len(fmt.Sprintf("%d", len(lines)))
248 for i, l := range lines {
249 digit := fmt.Sprintf("%*d", mll, i+1)
250 bar := "│"
251 digit = lineDigitStyle.Render(digit)
252 bar = lineBarStyle.Render(bar)
253 if i < len(lines)-1 || len(l) != 0 {
254 // If the final line was a newline we'll get an empty string for
255 // the final line, so drop the newline altogether.
256 lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
257 }
258 }
259 return strings.Join(lines, "\n"), mll
260}