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 = r.common.Styles.Renderer.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 if err != nil {
217 return "", err
218 }
219 mdt, err := tr.Render(md)
220 if err != nil {
221 return "", err
222 }
223 return mdt, nil
224}
225
226func (r *Code) renderFile(path, content string) (string, error) {
227 lexer := lexers.Match(path)
228 if path == "" {
229 lexer = lexers.Analyse(content)
230 }
231 lang := ""
232 if lexer != nil && lexer.Config() != nil {
233 lang = lexer.Config().Name
234 }
235
236 formatter := &gansi.CodeBlockElement{
237 Code: content,
238 Language: lang,
239 }
240 s := strings.Builder{}
241 rc := r.renderContext
242 if r.ShowLineNumber {
243 st := common.StyleConfig()
244 var m uint
245 st.CodeBlock.Margin = &m
246 rc = gansi.NewRenderContext(gansi.Options{
247 ColorProfile: termenv.TrueColor,
248 Styles: st,
249 })
250 }
251 err := formatter.Render(&s, rc)
252 if err != nil {
253 return "", err
254 }
255
256 return s.String(), nil
257}