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