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 if w > 120 {
168 w = 120
169 }
170 tr, err := glamour.NewTermRenderer(
171 glamour.WithStyles(r.styleConfig),
172 glamour.WithWordWrap(w),
173 )
174
175 if err != nil {
176 return "", err
177 }
178 mdt, err := tr.Render(md)
179 if err != nil {
180 return "", err
181 }
182 return mdt, nil
183}
184
185func (r *Code) renderFile(path, content string, width int) (string, error) {
186 // FIXME chroma & glamour might break wrapping when using tabs since tab
187 // width depends on the terminal. This is a workaround to replace tabs with
188 // 4-spaces.
189 content = strings.ReplaceAll(content, "\t", strings.Repeat(" ", tabWidth))
190 lexer := lexers.Fallback
191 if path == "" {
192 lexer = lexers.Analyse(content)
193 } else {
194 lexer = lexers.Match(path)
195 }
196 lang := ""
197 if lexer != nil && lexer.Config() != nil {
198 lang = lexer.Config().Name
199 }
200 var c string
201 if lang == "markdown" {
202 md, err := r.glamourize(width, content)
203 if err != nil {
204 return "", err
205 }
206 c = md
207 } else {
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 }
234 // Fix styling when after line breaks.
235 // https://github.com/muesli/reflow/issues/43
236 //
237 // TODO: solve this upstream in Glamour/Reflow.
238 return lipgloss.NewStyle().Width(width).Render(c), nil
239}
240
241func withLineNumber(s string) (string, int) {
242 lines := strings.Split(s, "\n")
243 // NB: len() is not a particularly safe way to count string width (because
244 // it's counting bytes instead of runes) but in this case it's okay
245 // because we're only dealing with digits, which are one byte each.
246 mll := len(fmt.Sprintf("%d", len(lines)))
247 for i, l := range lines {
248 digit := fmt.Sprintf("%*d", mll, i+1)
249 bar := "│"
250 digit = lineDigitStyle.Render(digit)
251 bar = lineBarStyle.Render(bar)
252 if i < len(lines)-1 || len(l) != 0 {
253 // If the final line was a newline we'll get an empty string for
254 // the final line, so drop the newline altogether.
255 lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
256 }
257 }
258 return strings.Join(lines, "\n"), mll
259}