1package code
2
3import (
4 "math"
5 "strings"
6 "sync"
7
8 "github.com/alecthomas/chroma/v2/lexers"
9 tea "github.com/charmbracelet/bubbletea/v2"
10 "github.com/charmbracelet/glamour"
11 gansi "github.com/charmbracelet/glamour/ansi"
12 "github.com/charmbracelet/lipgloss/v2"
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.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 && common.IsFileMarkdown(content, r.extension) {
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.Top, 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// ScrollPercent returns the viewport's scroll percentage.
173func (r *Code) ScrollPercent() float64 {
174 return r.Viewport.ScrollPercent()
175}
176
177// ScrollPosition returns the viewport's scroll position.
178func (r *Code) ScrollPosition() int {
179 scroll := r.ScrollPercent() * 100
180 if scroll < 0 || math.IsNaN(scroll) {
181 scroll = 0
182 }
183 return int(scroll)
184}
185
186func (r *Code) glamourize(w int, md string) (string, error) {
187 r.renderMutex.Lock()
188 defer r.renderMutex.Unlock()
189 if w > 120 {
190 w = 120
191 }
192 tr, err := glamour.NewTermRenderer(
193 glamour.WithStyles(r.styleConfig),
194 glamour.WithWordWrap(w),
195 )
196 if err != nil {
197 return "", err
198 }
199 mdt, err := tr.Render(md)
200 if err != nil {
201 return "", err
202 }
203 return mdt, nil
204}
205
206func (r *Code) renderFile(path, content string) (string, error) {
207 lexer := lexers.Match(path)
208 if path == "" {
209 lexer = lexers.Analyse(content)
210 }
211 lang := ""
212 if lexer != nil && lexer.Config() != nil {
213 lang = lexer.Config().Name
214 }
215
216 formatter := &gansi.CodeBlockElement{
217 Code: content,
218 Language: lang,
219 }
220 s := strings.Builder{}
221 rc := r.renderContext
222 if r.ShowLineNumber {
223 st := common.StyleConfig()
224 var m uint
225 st.CodeBlock.Margin = &m
226 rc = gansi.NewRenderContext(gansi.Options{
227 ColorProfile: termenv.TrueColor,
228 Styles: st,
229 })
230 }
231 err := formatter.Render(&s, rc)
232 if err != nil {
233 return "", err
234 }
235
236 return s.String(), nil
237}