1package code23import (4 "math"5 "strings"6 "sync"78 tea "charm.land/bubbletea/v2"9 "charm.land/glamour/v2"10 gansi "charm.land/glamour/v2/ansi"11 "charm.land/lipgloss/v2"12 "github.com/alecthomas/chroma/v2/lexers"13 "github.com/charmbracelet/soft-serve/pkg/ui/common"14 vp "github.com/charmbracelet/soft-serve/pkg/ui/components/viewport"15)1617const (18 defaultTabWidth = 419 defaultSideNotePercent = 0.320)2122// Code is a code snippet.23type Code struct {24 *vp.Viewport25 common common.Common26 sidenote string27 content string28 extension string29 renderContext gansi.RenderContext30 renderMutex sync.Mutex31 styleConfig gansi.StyleConfig3233 SideNotePercent float6434 TabWidth int35 ShowLineNumber bool36 NoContentStyle lipgloss.Style37 UseGlamour bool38}3940// 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 = st53 r.renderContext = common.StyleRendererWithStyles(st)54 r.SetSize(c.Width, c.Height)55 return r56}5758// SetSize implements common.Component.59func (r *Code) SetSize(width, height int) {60 r.common.SetSize(width, height)61 r.Viewport.SetSize(width, height)62}6364// SetContent sets the content of the Code.65func (r *Code) SetContent(c, ext string) tea.Cmd {66 r.content = c67 r.extension = ext68 return r.Init()69}7071// SetSideNote sets the sidenote of the Code.72func (r *Code) SetSideNote(s string) tea.Cmd {73 r.sidenote = s74 return r.Init()75}7677// Init implements tea.Model.78func (r *Code) Init() tea.Cmd {79 // XXX: We probably won't need the GetHorizontalFrameSize margin80 // subtraction if we get the new viewport soft wrapping to play nicely with81 // Glamour. This also introduces a bug where when it soft wraps, the82 // 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.content85 if content == "" {86 r.Viewport.Model.SetContent(r.NoContentStyle.String())87 return nil88 }8990 // FIXME chroma & glamour might break wrapping when using tabs since tab91 // width depends on the terminal. This is a workaround to replace tabs with92 // 4-spaces.93 content = strings.ReplaceAll(content, "\t", strings.Repeat(" ", r.TabWidth))9495 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 = md101 } else {102 f, err := r.renderFile(r.extension, content)103 if err != nil {104 return common.ErrorCmd(err)105 }106 content = f107 if r.ShowLineNumber {108 var ml int109 content, ml = common.FormatLineNumber(r.common.Styles, content, true)110 w -= ml111 }112 }113114 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.Top, strings.Join(lines, "\n"), content)121 }122123 // Fix styles after hard wrapping124 // https://github.com/muesli/reflow/issues/43125 //126 // TODO: solve this upstream in Glamour/Reflow.127 content = lipgloss.NewStyle().Width(w).Render(content)128129 r.Viewport.Model.SetContent(content)130131 return nil132}133134// Update implements tea.Model.135func (r *Code) Update(msg tea.Msg) (common.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}149150// View implements tea.View.151func (r *Code) View() string {152 return r.Viewport.View()153}154155// GotoTop moves the viewport to the top of the log.156func (r *Code) GotoTop() {157 r.Viewport.GotoTop()158}159160// GotoBottom moves the viewport to the bottom of the log.161func (r *Code) GotoBottom() {162 r.Viewport.GotoBottom()163}164165// HalfViewDown moves the viewport down by half the viewport height.166func (r *Code) HalfViewDown() {167 r.Viewport.HalfViewDown()168}169170// HalfViewUp moves the viewport up by half the viewport height.171func (r *Code) HalfViewUp() {172 r.Viewport.HalfViewUp()173}174175// ScrollPercent returns the viewport's scroll percentage.176func (r *Code) ScrollPercent() float64 {177 return r.Viewport.ScrollPercent()178}179180// ScrollPosition returns the viewport's scroll position.181func (r *Code) ScrollPosition() int {182 scroll := r.ScrollPercent() * 100183 if scroll < 0 || math.IsNaN(scroll) {184 scroll = 0185 }186 return int(scroll)187}188189func (r *Code) glamourize(w int, md string) (string, error) {190 r.renderMutex.Lock()191 defer r.renderMutex.Unlock()192 if w > 120 {193 w = 120194 }195 tr, err := glamour.NewTermRenderer(196 glamour.WithStyles(r.styleConfig),197 glamour.WithWordWrap(w),198 )199 if err != nil {200 return "", err201 }202 mdt, err := tr.Render(md)203 if err != nil {204 return "", err205 }206 return mdt, nil207}208209func (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().Name217 }218219 formatter := &gansi.CodeBlockElement{220 Code: content,221 Language: lang,222 }223 s := strings.Builder{}224 rc := r.renderContext225 if r.ShowLineNumber {226 st := common.StyleConfig()227 var m uint228 st.CodeBlock.Margin = &m229 rc = gansi.NewRenderContext(gansi.Options{230 Styles: st,231 })232 }233 err := formatter.Render(&s, rc)234 if err != nil {235 return "", err236 }237238 return s.String(), nil239}