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