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
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.CodeNoContent.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.(type) {
103 case tea.WindowSizeMsg:
104 // Recalculate content width and line wrap.
105 cmds = append(cmds, r.Init())
106 }
107 v, cmd := r.Viewport.Update(msg)
108 r.Viewport = v.(*vp.Viewport)
109 if cmd != nil {
110 cmds = append(cmds, cmd)
111 }
112 return r, tea.Batch(cmds...)
113}
114
115// View implements tea.View.
116func (r *Code) View() string {
117 return r.Viewport.View()
118}
119
120// GotoTop moves the viewport to the top of the log.
121func (r *Code) GotoTop() {
122 r.Viewport.GotoTop()
123}
124
125// GotoBottom moves the viewport to the bottom of the log.
126func (r *Code) GotoBottom() {
127 r.Viewport.GotoBottom()
128}
129
130// HalfViewDown moves the viewport down by half the viewport height.
131func (r *Code) HalfViewDown() {
132 r.Viewport.HalfViewDown()
133}
134
135// HalfViewUp moves the viewport up by half the viewport height.
136func (r *Code) HalfViewUp() {
137 r.Viewport.HalfViewUp()
138}
139
140// ViewUp moves the viewport up by a page.
141func (r *Code) ViewUp() []string {
142 return r.Viewport.ViewUp()
143}
144
145// ViewDown moves the viewport down by a page.
146func (r *Code) ViewDown() []string {
147 return r.Viewport.ViewDown()
148}
149
150// LineUp moves the viewport up by the given number of lines.
151func (r *Code) LineUp(n int) []string {
152 return r.Viewport.LineUp(n)
153}
154
155// LineDown moves the viewport down by the given number of lines.
156func (r *Code) LineDown(n int) []string {
157 return r.Viewport.LineDown(n)
158}
159
160// ScrollPercent returns the viewport's scroll percentage.
161func (r *Code) ScrollPercent() float64 {
162 return r.Viewport.ScrollPercent()
163}
164
165func (r *Code) glamourize(w int, md string) (string, error) {
166 r.renderMutex.Lock()
167 defer r.renderMutex.Unlock()
168 // This fixes a bug with markdown text wrapping being off by one.
169 if w > 0 {
170 w--
171 }
172 tr, err := glamour.NewTermRenderer(
173 glamour.WithStyles(r.styleConfig),
174 glamour.WithWordWrap(w),
175 )
176
177 if err != nil {
178 return "", err
179 }
180 mdt, err := tr.Render(md)
181 if err != nil {
182 return "", err
183 }
184 return mdt, nil
185}
186
187func (r *Code) renderFile(path, content string, width int) (string, error) {
188 // FIXME chroma & glamour might break wrapping when using tabs since tab
189 // width depends on the terminal. This is a workaround to replace tabs with
190 // 4-spaces.
191 content = strings.ReplaceAll(content, "\t", strings.Repeat(" ", tabWidth))
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 rc := r.renderContext
215 if r.showLineNumber {
216 st := common.StyleConfig()
217 m := uint(0)
218 st.CodeBlock.Margin = &m
219 rc = gansi.NewRenderContext(gansi.Options{
220 ColorProfile: termenv.TrueColor,
221 Styles: st,
222 })
223 }
224 err := formatter.Render(&s, rc)
225 if err != nil {
226 return "", err
227 }
228 c := s.String()
229 if r.showLineNumber {
230 c = withLineNumber(c)
231 }
232 // FIXME: this is a hack to reset formatting at the end of every line.
233 c = wrap.String(c, width)
234 f := strings.Split(c, "\n")
235 for i, l := range f {
236 f[i] = l + "\x1b[0m"
237 }
238 return strings.Join(f, "\n"), nil
239}
240
241func withLineNumber(s string) string {
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")
259}