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