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