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