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/v2"
11 gansi "github.com/charmbracelet/glamour/v2/ansi"
12 lipgloss "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)
16
17const (
18 defaultTabWidth = 4
19 defaultSideNotePercent = 0.3
20)
21
22// Code is a code snippet.
23type Code struct {
24 *vp.Viewport
25 common common.Common
26 sidenote string
27 content string
28 extension string
29 renderContext gansi.RenderContext
30 renderMutex sync.Mutex
31 styleConfig gansi.StyleConfig
32
33 SideNotePercent float64
34 TabWidth int
35 ShowLineNumber bool
36 NoContentStyle lipgloss.Style
37 UseGlamour bool
38}
39
40// New returns a new Code.
41func New(c common.Common, content, extension string) *Code {
42 r := &Code{
43 common: c,
44 content: content,
45 extension: extension,
46 TabWidth: defaultTabWidth,
47 SideNotePercent: defaultSideNotePercent,
48 Viewport: vp.New(c),
49 NoContentStyle: c.Styles.NoContent.SetString("No Content."),
50 }
51 st := common.StyleConfig()
52 r.styleConfig = st
53 r.renderContext = common.StyleRendererWithStyles(st)
54 r.SetSize(c.Width, c.Height)
55 return r
56}
57
58// SetSize implements common.Component.
59func (r *Code) SetSize(width, height int) {
60 r.common.SetSize(width, height)
61 r.Viewport.SetSize(width, height)
62}
63
64// SetContent sets the content of the Code.
65func (r *Code) SetContent(c, ext string) tea.Cmd {
66 r.content = c
67 r.extension = ext
68 return r.Init()
69}
70
71// SetSideNote sets the sidenote of the Code.
72func (r *Code) SetSideNote(s string) tea.Cmd {
73 r.sidenote = s
74 return r.Init()
75}
76
77// Init implements tea.Model.
78func (r *Code) Init() tea.Cmd {
79 // XXX: We probably won't need the GetHorizontalFrameSize margin
80 // subtraction if we get the new viewport soft wrapping to play nicely with
81 // Glamour. This also introduces a bug where when it soft wraps, the
82 // viewport scrolls left/right for 2 columns on each side of the screen.
83 w := r.common.Width - r.common.Styles.App.GetHorizontalFrameSize()
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 && common.IsFileMarkdown(content, r.extension) {
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.Viewport.Width()) * r.SideNotePercent))
117 for i, l := range lines {
118 lines[i] = common.TruncateString(l, sideNoteWidth)
119 }
120 content = lipgloss.JoinHorizontal(lipgloss.Top, 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// ScrollPercent returns the viewport's scroll percentage.
176func (r *Code) ScrollPercent() float64 {
177 return r.Viewport.ScrollPercent()
178}
179
180// ScrollPosition returns the viewport's scroll position.
181func (r *Code) ScrollPosition() int {
182 scroll := r.ScrollPercent() * 100
183 if scroll < 0 || math.IsNaN(scroll) {
184 scroll = 0
185 }
186 return int(scroll)
187}
188
189func (r *Code) glamourize(w int, md string) (string, error) {
190 r.renderMutex.Lock()
191 defer r.renderMutex.Unlock()
192 if w > 120 {
193 w = 120
194 }
195 tr, err := glamour.NewTermRenderer(
196 glamour.WithStyles(r.styleConfig),
197 glamour.WithWordWrap(w),
198 )
199 if err != nil {
200 return "", err
201 }
202 mdt, err := tr.Render(md)
203 if err != nil {
204 return "", err
205 }
206 return mdt, nil
207}
208
209func (r *Code) renderFile(path, content string) (string, error) {
210 lexer := lexers.Match(path)
211 if path == "" {
212 lexer = lexers.Analyse(content)
213 }
214 lang := ""
215 if lexer != nil && lexer.Config() != nil {
216 lang = lexer.Config().Name
217 }
218
219 formatter := &gansi.CodeBlockElement{
220 Code: content,
221 Language: lang,
222 }
223 s := strings.Builder{}
224 rc := r.renderContext
225 if r.ShowLineNumber {
226 st := common.StyleConfig()
227 var m uint
228 st.CodeBlock.Margin = &m
229 rc = gansi.NewRenderContext(gansi.Options{
230 Styles: st,
231 })
232 }
233 err := formatter.Render(&s, rc)
234 if err != nil {
235 return "", err
236 }
237
238 return s.String(), nil
239}