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