1package nbview23import (4 "strings"56 "charm.land/lipgloss/v2"7 "github.com/charmbracelet/x/ansi"89 "src.rest/cat/012/internal/ui/theme"10)1112// Note cells are Markdown, drawn as terminal text: headings bold (the13// first level underlined too), **bold**, *italic* and _italic_, `code`14// in the code's color, links as hyperlinks the terminal opens (OSC 8),15// list items with a bullet, quotes behind a bar, fenced code as it is,16// and paragraphs wrapped to the width. Each cue survives without color:17// the text's own attributes, bullets and bars.1819// markdown draws src at width as lines.20func markdown(th *theme.Theme, src string, width int) []string {21 var out []string22 fenced := false23 for line := range strings.SplitSeq(src, "\n") {24 trimmed := strings.TrimSpace(line)25 if strings.HasPrefix(trimmed, "```") {26 fenced = !fenced27 continue28 }29 if fenced {30 out = append(out, wrapStyled(th.Code[theme.SyntaxString], line, width)...)31 continue32 }33 out = append(out, mdLine(th, line, trimmed, width)...)34 }35 return out36}3738// mdLine draws one line of Markdown outside a fence.39func mdLine(th *theme.Theme, line, trimmed string, width int) []string {40 switch {41 case trimmed == "":42 return []string{""}43 case strings.Trim(trimmed, "-*_ ") == "" && len(strings.ReplaceAll(trimmed, " ", "")) >= 3:44 return []string{th.Muted.Render(strings.Repeat("─", max(width, 1)))}45 case strings.HasPrefix(trimmed, "#"):46 level := len(trimmed) - len(strings.TrimLeft(trimmed, "#"))47 text := strings.TrimSpace(trimmed[level:])48 style := th.Title49 if level == 1 {50 style = style.Underline(true)51 }52 return prefixed("", "", inline(th, style, text), width)53 case strings.HasPrefix(trimmed, "> "):54 return prefixed(th.Muted.Render("│ "), th.Muted.Render("│ "), inline(th, lipgloss.NewStyle(), trimmed[2:]), width)55 }56 indent := line[:len(line)-len(strings.TrimLeft(line, " "))]57 if item, ok := listItem(trimmed); ok {58 lead := indent + "• "59 return prefixed(lead, strings.Repeat(" ", ansi.StringWidth(lead)), inline(th, lipgloss.NewStyle(), item), width)60 }61 if n, item, ok := numbered(trimmed); ok {62 lead := indent + n + " "63 return prefixed(lead, strings.Repeat(" ", ansi.StringWidth(lead)), inline(th, lipgloss.NewStyle(), item), width)64 }65 return prefixed("", "", inline(th, lipgloss.NewStyle(), trimmed), width)66}6768func listItem(s string) (string, bool) {69 for _, m := range []string{"- ", "* ", "+ "} {70 if rest, ok := strings.CutPrefix(s, m); ok {71 return rest, true72 }73 }74 return "", false75}7677func numbered(s string) (string, string, bool) {78 i := 079 for i < len(s) && isDigit(s[i]) {80 i++81 }82 if i == 0 || i+1 >= len(s) || s[i] != '.' && s[i] != ')' || s[i+1] != ' ' {83 return "", "", false84 }85 return s[:i+1], s[i+2:], true86}8788// prefixed wraps styled text to width after first on its first line and89// rest on the others.90func prefixed(first, rest, styled string, width int) []string {91 room := max(width-ansi.StringWidth(first), 4)92 lines := strings.Split(ansi.Wrap(styled, room, " -"), "\n")93 for i := range lines {94 if i == 0 {95 lines[i] = first + lines[i]96 } else {97 lines[i] = rest + lines[i]98 }99 }100 return lines101}102103func wrapStyled(style lipgloss.Style, s string, width int) []string {104 var out []string105 for _, l := range wrapText(s, max(width, 4)) {106 out = append(out, style.Render(l))107 }108 return out109}110111// inline draws a line's inline Markdown on base: emphasis, code spans112// and links.113func inline(th *theme.Theme, base lipgloss.Style, s string) string {114 var b strings.Builder115 plain := func(t string) {116 if t != "" {117 b.WriteString(base.Render(t))118 }119 }120 for s != "" {121 i := strings.IndexAny(s, "*_`[")122 if i < 0 {123 plain(s)124 break125 }126 plain(s[:i])127 s = s[i:]128 text, rest, style, link, ok := span(th, base, s)129 if !ok {130 plain(s[:1])131 s = s[1:]132 continue133 }134 if link != "" {135 style = style.Hyperlink(link)136 }137 b.WriteString(style.Render(text))138 s = rest139 }140 return b.String()141}142143// span reads the inline span s starts with: its text, what follows it,144// its style and a link's URL.145func span(th *theme.Theme, base lipgloss.Style, s string) (text, rest string, style lipgloss.Style, link string, ok bool) {146 switch {147 case strings.HasPrefix(s, "**") || strings.HasPrefix(s, "__"):148 if j := strings.Index(s[2:], s[:2]); j > 0 {149 return s[2 : 2+j], s[4+j:], base.Bold(true), "", true150 }151 case s[0] == '*' || s[0] == '_':152 if j := strings.IndexByte(s[1:], s[0]); j > 0 {153 return s[1 : 1+j], s[2+j:], base.Italic(true), "", true154 }155 case s[0] == '`':156 if j := strings.IndexByte(s[1:], '`'); j >= 0 {157 return s[1 : 1+j], s[2+j:], th.Code[theme.SyntaxCommand], "", true158 }159 case s[0] == '[':160 if j := strings.Index(s, "]("); j > 0 {161 if k := strings.IndexByte(s[j:], ')'); k > 0 {162 return s[1:j], s[j+k+1:], base.Inherit(th.Link), s[j+2 : j+k], true163 }164 }165 }166 return "", "", base, "", false167}