1// Package code provides code syntax highlighting components.
2package code
3
4import (
5 "math"
6 "strings"
7 "sync"
8
9 "github.com/alecthomas/chroma/v2/lexers"
10 tea "github.com/charmbracelet/bubbletea/v2"
11 "github.com/charmbracelet/glamour/v2"
12 gansi "github.com/charmbracelet/glamour/v2/ansi"
13 "github.com/charmbracelet/lipgloss/v2"
14 "github.com/charmbracelet/soft-serve/pkg/ui/common"
15 vp "github.com/charmbracelet/soft-serve/pkg/ui/components/viewport"
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.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 // XXX: We probably won't need the GetHorizontalFrameSize margin
81 // subtraction if we get the new viewport soft wrapping to play nicely with
82 // Glamour. This also introduces a bug where when it soft wraps, the
83 // viewport scrolls left/right for 2 columns on each side of the screen.
84 w := r.common.Width - r.common.Styles.App.GetHorizontalFrameSize()
85 content := r.content
86 if content == "" {
87 r.Model.SetContent(r.NoContentStyle.String())
88 return nil
89 }
90
91 // FIXME chroma & glamour might break wrapping when using tabs since tab
92 // width depends on the terminal. This is a workaround to replace tabs with
93 // 4-spaces.
94 content = strings.ReplaceAll(content, "\t", strings.Repeat(" ", r.TabWidth))
95
96 if r.UseGlamour && common.IsFileMarkdown(content, r.extension) {
97 md, err := r.glamourize(w, content)
98 if err != nil {
99 return common.ErrorCmd(err)
100 }
101 content = md
102 } else {
103 f, err := r.renderFile(r.extension, content)
104 if err != nil {
105 return common.ErrorCmd(err)
106 }
107 content = f
108 if r.ShowLineNumber {
109 var ml int
110 content, ml = common.FormatLineNumber(r.common.Styles, content, true)
111 w -= ml
112 }
113 }
114
115 if r.sidenote != "" {
116 lines := strings.Split(r.sidenote, "\n")
117 sideNoteWidth := int(math.Ceil(float64(r.Width()) * r.SideNotePercent))
118 for i, l := range lines {
119 lines[i] = common.TruncateString(l, sideNoteWidth)
120 }
121 content = lipgloss.JoinHorizontal(lipgloss.Top, strings.Join(lines, "\n"), content)
122 }
123
124 // Fix styles after hard wrapping
125 // https://github.com/muesli/reflow/issues/43
126 //
127 // TODO: solve this upstream in Glamour/Reflow.
128 content = lipgloss.NewStyle().Width(w).Render(content)
129
130 r.Model.SetContent(content)
131
132 return nil
133}
134
135// Update implements tea.Model.
136func (r *Code) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
137 cmds := make([]tea.Cmd, 0)
138 switch msg.(type) {
139 case tea.WindowSizeMsg:
140 // Recalculate content width and line wrap.
141 cmds = append(cmds, r.Init())
142 }
143 v, cmd := r.Viewport.Update(msg)
144 r.Viewport = v.(*vp.Viewport)
145 if cmd != nil {
146 cmds = append(cmds, cmd)
147 }
148 return r, tea.Batch(cmds...)
149}
150
151// View implements tea.View.
152func (r *Code) View() string {
153 return r.Viewport.View()
154}
155
156// GotoTop moves the viewport to the top of the log.
157func (r *Code) GotoTop() {
158 r.Viewport.GotoTop()
159}
160
161// GotoBottom moves the viewport to the bottom of the log.
162func (r *Code) GotoBottom() {
163 r.Viewport.GotoBottom()
164}
165
166// HalfViewDown moves the viewport down by half the viewport height.
167func (r *Code) HalfViewDown() {
168 r.Viewport.HalfViewDown()
169}
170
171// HalfViewUp moves the viewport up by half the viewport height.
172func (r *Code) HalfViewUp() {
173 r.Viewport.HalfViewUp()
174}
175
176// ScrollPercent returns the viewport's scroll percentage.
177func (r *Code) ScrollPercent() float64 {
178 return r.Viewport.ScrollPercent()
179}
180
181// ScrollPosition returns the viewport's scroll position.
182func (r *Code) ScrollPosition() int {
183 scroll := r.ScrollPercent() * 100
184 if scroll < 0 || math.IsNaN(scroll) {
185 scroll = 0
186 }
187 return int(scroll)
188}
189
190func (r *Code) glamourize(w int, md string) (string, error) {
191 r.renderMutex.Lock()
192 defer r.renderMutex.Unlock()
193 if w > 120 {
194 w = 120
195 }
196 tr, err := glamour.NewTermRenderer(
197 glamour.WithStyles(r.styleConfig),
198 glamour.WithWordWrap(w),
199 )
200 if err != nil {
201 return "", err //nolint:wrapcheck
202 }
203 mdt, err := tr.Render(md)
204 if err != nil {
205 return "", err //nolint:wrapcheck
206 }
207 return mdt, nil
208}
209
210func (r *Code) renderFile(path, content string) (string, error) {
211 lexer := lexers.Match(path)
212 if path == "" {
213 lexer = lexers.Analyse(content)
214 }
215 lang := ""
216 if lexer != nil && lexer.Config() != nil {
217 lang = lexer.Config().Name
218 }
219
220 formatter := &gansi.CodeBlockElement{
221 Code: content,
222 Language: lang,
223 }
224 s := strings.Builder{}
225 rc := r.renderContext
226 if r.ShowLineNumber {
227 st := common.StyleConfig()
228 var m uint
229 st.CodeBlock.Margin = &m
230 rc = gansi.NewRenderContext(gansi.Options{
231 Styles: st,
232 })
233 }
234 err := formatter.Render(&s, rc)
235 if err != nil {
236 return "", err //nolint:wrapcheck
237 }
238
239 return s.String(), nil
240}